@vzn/vx 0.0.636 → 0.0.637

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 (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -8,11 +8,17 @@
8
8
  // See docs/design/pipeline-2026-09.md.
9
9
 
10
10
  import path from 'node:path'
11
- import { ChainedCache, type CacheLayer } from '../cache/index.js'
11
+ import {
12
+ type Cache,
13
+ ChainedCache,
14
+ type CacheLayer,
15
+ type CachePolicy,
16
+ LayeredCache,
17
+ } from '../cache/index.js'
12
18
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
13
19
  import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
14
- import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
15
- import { checkGraph, type TaskNode } from '../graph/index.js'
20
+ import type { ProjectConfig, WorkspaceConfig } from '../config.js'
21
+ import { checkGraph, type GraphRules, type TaskNode } from '../graph/index.js'
16
22
  import type {
17
23
  CacheContext,
18
24
  DiscoverContext,
@@ -21,7 +27,7 @@ import type {
21
27
  FingerprintContext,
22
28
  GraphHookContext,
23
29
  KeyHookContext,
24
- NamedProject,
30
+ DiscoveredProject,
25
31
  ProjectHookContext,
26
32
  ScheduleHookContext,
27
33
  VxPlugin,
@@ -46,24 +52,8 @@ function isRecord(v: unknown): v is Record<string, unknown> {
46
52
  return proto === Object.prototype || proto === null
47
53
  }
48
54
 
49
- /** Every method `CacheLayer` requires (the optional ones are probed with `?.`). */
50
- export const CACHE_LAYER_METHODS: readonly string[] = [
51
- 'key',
52
- 'get',
53
- 'has',
54
- 'prefetch',
55
- 'loadOutputFilesBatch',
56
- 'isOutputsCurrent',
57
- 'restoreOutputs',
58
- 'save',
59
- 'ingest',
60
- 'recordRunBundle',
61
- 'stats',
62
- 'hashFile',
63
- 'outputsPath',
64
- 'prune',
65
- 'close',
66
- ]
55
+ /** Every method a `cache` hook's `RemoteCacheLayer` requires (`hasMany` is optional). */
56
+ export const REMOTE_CACHE_METHODS: readonly string[] = ['has', 'get', 'put']
67
57
 
68
58
  /** What a capability hook handed back must be what the seam runs. */
69
59
  function assertShape(
@@ -96,7 +86,7 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
96
86
  return await fn()
97
87
  } catch (err) {
98
88
  // Masked here, not by whoever prints it: an embedder of `run()` and
99
- // `vx mcp` print it as they get it, and the throw quotes what the
89
+ // a plugin's verb print it as they get it, and the throw quotes what the
100
90
  // plugin was told (a remote's reply, a header) (L-11).
101
91
  throw new UserError(
102
92
  maskedLine(
@@ -158,15 +148,21 @@ export async function applyConfigHooks(
158
148
 
159
149
  /**
160
150
  * `discover` stage: each plugin names directories to add to `projects`,
161
- * seeing the ones earlier plugins added. `add` turns one name into a meta
162
- * (null: already there) or refuses it.
151
+ * seeing the ones earlier plugins added. `add` turns one entry into the
152
+ * project it names, `added` when it is new, or refuses it. Returns every
153
+ * entry's edges with the project and plugin they came from, for the caller
154
+ * to resolve once every plugin has run.
163
155
  */
164
156
  export async function applyDiscoverHooks(
165
157
  plugins: readonly VxPlugin[],
166
158
  projects: ProjectMeta[],
167
159
  ctx: Omit<DiscoverContext, 'projects'>,
168
- add: (named: NamedProject, plugin: VxPlugin) => Promise<ProjectMeta | null>,
169
- ): Promise<void> {
160
+ add: (
161
+ entry: DiscoveredProject,
162
+ plugin: VxPlugin,
163
+ ) => Promise<{ meta: ProjectMeta; added: boolean }>,
164
+ ): Promise<DiscoveredEdges[]> {
165
+ const edges: DiscoveredEdges[] = []
170
166
  for (const plugin of plugins) {
171
167
  if (plugin.discover === undefined) continue
172
168
  const named = await safe(plugin, 'discover', () => plugin.discover!({ ...ctx, projects }))
@@ -175,11 +171,23 @@ export async function applyDiscoverHooks(
175
171
  `plugin '${plugin.name}' failed in discover: expected an array, got ${describeValue(named)}`,
176
172
  )
177
173
  }
178
- for (const n of named as readonly NamedProject[]) {
179
- const meta = await add(n, plugin)
180
- if (meta !== null) projects.push(meta)
174
+ for (const entry of named as readonly DiscoveredProject[]) {
175
+ const { meta, added } = await add(entry, plugin)
176
+ if (added) projects.push(meta)
177
+ if (entry.deps !== undefined || entry.reach !== undefined) {
178
+ edges.push({ by: plugin.name, meta, deps: entry.deps, reach: entry.reach ?? [] })
179
+ }
181
180
  }
182
181
  }
182
+ return edges
183
+ }
184
+
185
+ export interface DiscoveredEdges {
186
+ readonly by: string
187
+ readonly meta: ProjectMeta
188
+ /** Absent when the entry carried none; `[]` still makes the workspace an edge source. */
189
+ readonly deps: readonly string[] | undefined
190
+ readonly reach: readonly string[]
183
191
  }
184
192
 
185
193
  /** `project` stage: every plugin edits one project's config in place. */
@@ -215,7 +223,7 @@ export async function applyGraphHooks(
215
223
  ctx: GraphHookContext,
216
224
  /** Runs after EACH plugin's edit, so a refused task config names the plugin that wrote it. */
217
225
  afterEach?: (plugin: VxPlugin) => void,
218
- rules?: WorkspaceRules,
226
+ rules?: GraphRules,
219
227
  ): Promise<void> {
220
228
  let last: VxPlugin | undefined
221
229
  for (const plugin of plugins) {
@@ -466,58 +474,74 @@ export function buildAdmission(
466
474
  }
467
475
  }
468
476
 
477
+ /** What `resolveCache` needs beyond a hook's context: the store and policy core owns. */
478
+ export interface CacheHost extends CacheContext {
479
+ readonly localCache: Cache
480
+ readonly policy: CachePolicy
481
+ }
482
+
469
483
  /**
470
- * Collect every plugin's `cache` layer in declaration order, with the local
471
- * store at the tail, and chain them (lookup walks them, save reaches all;
472
- * see ChainedCache). The local store is dropped when a declared layer wraps
473
- * it (`layer.local === ctx.localCache`), so a remote plugin that layers over
474
- * the local handle does not also write it directly. Only a single layer
475
- * left is used as is: the local store when no plugin declares one, or the
476
- * one layer that wraps it.
484
+ * Collect every plugin's remote (`cache` hook) in declaration order and
485
+ * layer each over the local store: a lookup asks the local store first,
486
+ * then each remote in order (owner, 2026-10-13), and a save reaches every
487
+ * remote. The run's policy, narrowed by a remote's `access`, and its scope
488
+ * gate each remote; a remote's throw is a miss and a warning naming its
489
+ * plugin. No remote → the local store alone.
477
490
  */
478
491
  export async function resolveCache(
479
492
  plugins: readonly VxPlugin[],
480
- ctx: CacheContext,
493
+ host: CacheHost,
481
494
  ): Promise<CacheLayer> {
495
+ const { localCache, policy, ...ctx } = host
482
496
  const layers: CacheLayer[] = []
483
- const owners = new Map<CacheLayer, string>()
497
+ const owners: string[] = []
484
498
  for (const plugin of plugins) {
485
499
  if (plugin.cache === undefined) continue
486
- const layer = await safe(plugin, 'cache', () => plugin.cache!(ctx))
487
- if (layer === undefined) continue
488
- // The seam's WHOLE contract, checked once here: a layer missing a
489
- // method otherwise failed at the first task that reached it — an
490
- // internal TypeError deep in the chain, naming neither the plugin nor
491
- // the hook. Five names were checked before, so a layer with those five
492
- // passed and died at its first hit inside restoreOutputs.
493
- assertShape(plugin, 'cache', layer, CACHE_LAYER_METHODS, 'a cache layer')
494
- layers.push(layer)
495
- if (!owners.has(layer)) owners.set(layer, `plugin '${plugin.name}'`)
500
+ const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
501
+ if (remote === undefined) continue
502
+ // The hook returned a whole layer until 0.0.637: name the fix, not the
503
+ // three methods such a layer happens to have.
504
+ if (isRecordLike(remote) && typeof remote['restoreOutputs'] === 'function') {
505
+ throw new UserError(
506
+ `plugin '${plugin.name}' returned a cache layer from cache: since vx 0.0.637 the hook ` +
507
+ 'returns the remote alone (a RemoteCacheLayer: has, get, put) and core layers it over ' +
508
+ 'the local cache — return the remote you passed to LayeredCache',
509
+ )
510
+ }
511
+ assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
512
+ const access = remote.access
513
+ const own: CachePolicy = {
514
+ ...policy,
515
+ remoteRead: policy.remoteRead && access?.read !== false,
516
+ remoteWrite: policy.remoteWrite && access?.write !== false,
517
+ }
518
+ layers.push(
519
+ new LayeredCache(localCache, remote, {
520
+ policy: own,
521
+ onRemoteError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
522
+ }),
523
+ )
524
+ owners.push(`the cache layer of plugin '${plugin.name}'`)
496
525
  }
497
- // Core's own store is the TAIL of the chain — the floor under every
498
- // lookup, not a plugin a workspace has to declare. A layer that WRAPS
499
- // the local handle subsumes it below, so a remote plugin does not write
500
- // the local store twice.
501
- if (!layers.includes(ctx.localCache)) layers.push(ctx.localCache)
502
- const wrapsLocal = layers.some((l) => l !== ctx.localCache && l.local === ctx.localCache)
503
- const distinct = wrapsLocal ? layers.filter((l) => l !== ctx.localCache) : layers
504
- if (distinct.length === 1) return distinct[0]!
526
+ if (layers.length === 0) return localCache
527
+ if (layers.length === 1) return layers[0]!
505
528
  // One line per layer and method: a layer that throws on every lookup
506
529
  // would otherwise print once per task.
507
530
  const told = new Set<string>()
508
- return new ChainedCache(distinct, (i, method, err) => {
509
- const layer = distinct[i]!
510
- const who =
511
- layer === ctx.localCache ? 'the local cache' : (owners.get(layer) ?? 'a cache layer')
531
+ return new ChainedCache(layers, (i, method, err) => {
512
532
  if (told.has(`${i}\0${method}`)) return
513
533
  told.add(`${i}\0${method}`)
514
534
  const m = err instanceof Error ? err.message : String(err)
515
535
  const then =
516
536
  method === 'save' ? 'the other layers still save' : 'a miss there; the next layer answers'
517
- ctx.warn(`[vx] ${who} failed in cache ${method}: ${m}; ${then}`)
537
+ ctx.warn(`[vx] ${owners[i]} failed in cache ${method}: ${m}; ${then}`)
518
538
  })
519
539
  }
520
540
 
541
+ function isRecordLike(v: unknown): v is Record<string, unknown> {
542
+ return typeof v === 'object' && v !== null
543
+ }
544
+
521
545
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */
522
546
  const executorPlugin = new WeakMap<TaskExecutor, string>()
523
547
 
@@ -11,7 +11,7 @@
11
11
  import { readFileSync } from 'node:fs'
12
12
  import path from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
- import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
14
+ import type { Cache, RemoteCacheLayer } from '../cache/index.js'
15
15
  import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
16
16
  import type { TaskExecutor } from '../exec/index.js'
17
17
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
@@ -19,13 +19,14 @@ import { maskedLine, nearest, UserError } from '../util/index.js'
19
19
  import type { ProjectMeta } from '../workspace/index.js'
20
20
  import type { EventBus, RunStartInfo } from './events.js'
21
21
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
22
+ import type { Logger } from './logger.js'
22
23
 
23
24
  /**
24
25
  * A vx plugin. Contributes any subset of the run-level capabilities —
25
26
  * where work runs (executor), which cache is used (cache), who
26
27
  * observes the run (telemetry). It never changes WHAT a task is (the
27
28
  * command string — principle #3), only where and how that command is
28
- * executed. Registered explicitly in vx.workspace.ts via
29
+ * executed. Registered explicitly in .vx/workspace.ts via
29
30
  * defineWorkspace({ plugins: [...] }). No auto-discovery.
30
31
  *
31
32
  * Made by `definePlugin(import.meta, hooks)` only: the loader refuses a
@@ -59,24 +60,32 @@ export interface VxPlugin {
59
60
  config?(workspace: WorkspaceConfig, ctx: WorkspaceHookContext): void | Promise<void>
60
61
 
61
62
  /**
62
- * Directories to make projects beyond the package manager's members, as
63
- * `{ dir, name }` (`dir` absolute or relative to the workspace root): a
64
- * runner's root tasks, a project no member glob lists. Runs once per
65
- * discovery, before any project config loads; each plugin sees core's
66
- * projects and the ones earlier plugins named. A named directory keeps
67
- * a `package.json` of the same name, if it has one; a name or directory
68
- * already taken otherwise, or one outside the root, is refused.
63
+ * Projects beyond the directories holding a vx.config, their names, and
64
+ * edges between projects, as `DiscoveredProject`s (each directory
65
+ * absolute or relative to the workspace root): a lockfile's importers,
66
+ * their package names and the dependencies it records; a runner's root
67
+ * tasks. Runs once per discovery, before any project config loads; each
68
+ * plugin sees the locked projects and the ones earlier plugins added,
69
+ * without their edges. A directory that is not a project becomes one
70
+ * (with no config; named by `name`, else its path). A project named by
71
+ * its path takes `name`; one its config names keeps that name, and a
72
+ * different name from an earlier plugin is refused, as is a directory
73
+ * outside the root. Two projects of one name are refused once the stage
74
+ * ends. `deps` and `reach` from every plugin union then; a target that is
75
+ * no project is refused.
69
76
  */
70
- discover?(ctx: DiscoverContext): readonly NamedProject[] | Promise<readonly NamedProject[]>
77
+ discover?(
78
+ ctx: DiscoverContext,
79
+ ): readonly DiscoveredProject[] | Promise<readonly DiscoveredProject[]>
71
80
 
72
81
  /**
73
82
  * One project's validated config, right after it loaded and before the
74
83
  * graph is built. Add, remove or edit tasks. Runs for every loaded project
75
- * on every run — a config's cached evaluation is the user's file, and the
76
- * hook is applied on top of it live. A package with NO config file is
77
- * loaded too, as `{ tasks: {} }`, so a plugin can give tasks to packages
78
- * that never wrote one (`ctx.packageJson` carries their scripts); with no
79
- * `project` plugin such a package is never visited.
84
+ * on every run — a locked config is the user's file, and the hook is
85
+ * applied on top of it live. A project with NO config file (a
86
+ * `discover` plugin added it) is loaded too, as `{ tasks: {} }`, so a
87
+ * plugin can give it tasks; with no `project` plugin such a project is
88
+ * never visited.
80
89
  */
81
90
  project?(config: ProjectConfig, ctx: ProjectHookContext): void | Promise<void>
82
91
 
@@ -105,22 +114,14 @@ export interface VxPlugin {
105
114
  | Promise<Readonly<Record<string, string>> | undefined>
106
115
 
107
116
  /**
108
- * Workspace-root files this plugin keys on its own, taken OUT of the
109
- * workspace fingerprint. Core folds every lockfile it knows into every
110
- * task's key, so one `pnpm install` re-keys the whole workspace. A plugin
111
- * that reads the lockfile and folds each project's own dependency
112
- * closure through `key` claims the file here: core leaves it out of the
113
- * fingerprint every key sees (the config-evaluation cache still keys on
114
- * it — a config may import a dependency), and `--affected` asks
115
- * `affected` which projects a change to it touches instead of selecting
116
- * every project. A file has one claimant.
117
- *
118
- * A root file core does NOT fold is claimed the same way when the
119
- * plugin's stages read it: `turbo()` shapes every task from
120
- * `turbo.json`, so an edit to it re-keys tasks through their resolved
121
- * configs while no project owns the path. Nothing is taken out;
122
- * `--affected` asks `affected` (undefined: every project) and `vx watch`
123
- * re-runs on an edit (item 961). A claim is a bare name at the root.
117
+ * Workspace-root files this plugin keys on: core folds no root file into
118
+ * any key, so a plugin that reads one (a lockfile it folds per project
119
+ * through `key`, `turbo.json` that shapes every task through its
120
+ * `project` stage) claims it here. `--affected` asks `affected` which
121
+ * projects a change to it touches (undefined: every project), `vx watch`
122
+ * re-runs on an edit (item 961), and a task that rewrites one during the
123
+ * run withholds every later save. A claim is a bare name at the root; a
124
+ * file has one claimant.
124
125
  */
125
126
  readonly fingerprint?: FingerprintClaim
126
127
 
@@ -167,14 +168,14 @@ export interface VxPlugin {
167
168
  // --- BEHAVIOR capabilities (change WHAT/HOW work runs — opt-in) -----------
168
169
 
169
170
  /**
170
- * Contribute a cache layer. Returns a CacheLayer wrapping (or replacing)
171
- * the local Cache, or undefined to decline. Consulted ONCE per prepareRun.
172
- * Every contributed layer is kept and chained in declaration order,
173
- * ahead of core's own `.vx/cache` handle at the tail: a lookup walks
174
- * them until one hits, a save reaches each. A layer that WRAPS the local
175
- * handle subsumes that tail, so it is not written twice.
171
+ * Contribute a remote cache: the wire (`has`, `get`, `put`, optional
172
+ * `hasMany`), or undefined to decline. Consulted ONCE per prepareRun.
173
+ * Core layers each one behind its own local cache and owns the rest:
174
+ * the local store is asked first, then each remote in declaration order;
175
+ * the run's policy and scope gate every remote; a remote's throw is a
176
+ * miss and a warning naming the plugin, never a failed run.
176
177
  */
177
- cache?(ctx: CacheContext): CacheLayer | undefined | Promise<CacheLayer | undefined>
178
+ cache?(ctx: CacheContext): RemoteCacheLayer | undefined | Promise<RemoteCacheLayer | undefined>
178
179
 
179
180
  /**
180
181
  * Contribute a task executor — WHERE one task's command runs. Consulted
@@ -206,6 +207,16 @@ export interface VxPlugin {
206
207
  | undefined
207
208
  | Promise<TelemetrySink | TelemetrySink[] | undefined>
208
209
 
210
+ /**
211
+ * Take the terminal for this run: return the `Logger` that renders it
212
+ * (the contract `run({ log })` takes), or `undefined` to leave core's
213
+ * renderer in place. The first plugin, in declaration order, that
214
+ * returns one renders the run; the rest are not asked. Not consulted
215
+ * when an embedder passed its own `log`. A logger that throws hands the
216
+ * rest of the run back to core's renderer with one warning.
217
+ */
218
+ output?(ctx: OutputContext): Logger | undefined | Promise<Logger | undefined>
219
+
209
220
  /**
210
221
  * Optional one-time setup per run (validate the workspace, open a
211
222
  * connection, read a token). Throwing aborts the run with a clean
@@ -248,7 +259,7 @@ export interface PluginCommand {
248
259
  export interface CommandContext extends BaseContext {
249
260
  /**
250
261
  * The worker count a `vx run` here uses unless `--concurrency` says
251
- * otherwise: `vx.workspace`'s `concurrency`, else what the machine allows.
262
+ * otherwise: `.vx/workspace`'s `concurrency`, else what the machine allows.
252
263
  * A verb that budgets a run (`vx history`) reads it here; its own
253
264
  * `machineParallelism()` said 4 cores beside `concurrency: 1` (item 1068).
254
265
  */
@@ -256,7 +267,7 @@ export interface CommandContext extends BaseContext {
256
267
  /**
257
268
  * The argv that runs this vx again: the binary alone when compiled, the
258
269
  * runtime and its entry script from source. A verb that runs tasks
259
- * (`vx mcp`'s `runTasks`) spawns `[...vx, 'run', …]` rather than guess
270
+ * spawns `[...vx, 'run', …]` rather than guess
260
271
  * which `vx` a PATH holds.
261
272
  */
262
273
  readonly vx: readonly string[]
@@ -271,7 +282,7 @@ export interface WorkspaceHookContext {
271
282
  export interface DiscoverContext extends WorkspaceHookContext {
272
283
  /** Where vx's cache lives: a plugin may keep what it read to answer here. */
273
284
  readonly cacheDir: string
274
- /** The projects found so far: core's, then earlier plugins'. */
285
+ /** The projects found so far: the lock's, then earlier plugins'. */
275
286
  readonly projects: readonly ProjectMeta[]
276
287
  /**
277
288
  * What `git status` lists in the workspace, workspace-relative: modified,
@@ -283,24 +294,33 @@ export interface DiscoverContext extends WorkspaceHookContext {
283
294
  worktreeChanges(): Promise<readonly string[] | null>
284
295
  }
285
296
 
286
- export interface NamedProject {
297
+ /** What a `discover` hook returns per directory — see `VxPlugin.discover`. */
298
+ export interface DiscoveredProject {
299
+ /** Absolute or relative to the workspace root. */
287
300
  readonly dir: string
288
- readonly name: string
301
+ /**
302
+ * Its name, when its config names none: replaces a path name, never a
303
+ * config's; a different name from an earlier plugin is refused. Unset on
304
+ * a new directory: its root-relative path.
305
+ */
306
+ readonly name?: string
307
+ /** Directories this project builds after: `^task` order, and reach for `--filter` and `--affected`. */
308
+ readonly deps?: readonly string[]
309
+ /** Directories a change reaches this project through (`--filter`, `--affected`), with no order. */
310
+ readonly reach?: readonly string[]
289
311
  }
290
312
 
291
313
  export interface ProjectHookContext extends BaseContext {
292
- /** The package name (`package.json#name`). */
314
+ /** The project's name (`ProjectMeta.name`). */
293
315
  readonly name: string
294
316
  /** The project's directory, absolute. */
295
317
  readonly dir: string
296
- /** The parsed `package.json`, read-only. */
297
- readonly packageJson: Readonly<Record<string, unknown>>
298
318
  /**
299
- * Every package the workspace discovered — the one being visited, the
300
- * rest of the run's scope and the packages outside it, config file or
301
- * not — as core read them at startup. A plugin whose mapping needs the
302
- * whole workspace (a `dependsOn` is only valid against every package's
303
- * scripts at once) reads it here instead of walking the workspace a
319
+ * Every project the workspace discovered — the one being visited, the
320
+ * rest of the run's scope and the projects outside it, config file or
321
+ * not — as the `discover` stage left them. A plugin whose mapping needs
322
+ * the whole workspace (a `dependsOn` is only valid against every
323
+ * package's scripts at once) reads it here instead of walking the workspace a
304
324
  * second time. The same array is handed to every visit of a run and a
305
325
  * new one to the next run — a plugin instance outlives a run under `vx
306
326
  * watch`, so a per-run memo keys on this array's identity, never on the
@@ -320,16 +340,16 @@ export interface GraphHookContext extends BaseContext {
320
340
 
321
341
  export interface KeyHookContext extends BaseContext {}
322
342
 
323
- /** A plugin's claim on workspace fingerprint files — see `VxPlugin.fingerprint`. */
343
+ /** A plugin's claim on workspace-root files — see `VxPlugin.fingerprint`. */
324
344
  export interface FingerprintClaim {
325
- /** Bare names at the workspace root: a lockfile core folds (`pnpm-lock.yaml`) or a file the plugin reads (`turbo.json`). */
345
+ /** Bare names at the workspace root the plugin reads: a lockfile (`pnpm-lock.yaml`), a settings file, `turbo.json`. */
326
346
  readonly files: readonly string[]
327
347
  /**
328
- * The projects (package names) a change to a claimed file affects. Called
348
+ * The projects (by name) a change to a claimed file affects. Called
329
349
  * by `--affected` with the file's bytes at the base ref and in the
330
350
  * working tree (`null` where it does not exist). Return `undefined` to
331
351
  * select every project — the answer a claimant gives when it cannot
332
- * tell, and what core does for an unclaimed file.
352
+ * tell.
333
353
  */
334
354
  affected(
335
355
  change: FingerprintChange,
@@ -345,7 +365,7 @@ export interface FingerprintChange {
345
365
  }
346
366
 
347
367
  export interface FingerprintContext extends BaseContext {
348
- /** Every project in the workspace: package name and absolute directory. */
368
+ /** Every project in the workspace: name and absolute directory. */
349
369
  readonly projects: ReadonlyArray<{ readonly name: string; readonly dir: string }>
350
370
  }
351
371
 
@@ -364,13 +384,27 @@ export interface AdmitContext {
364
384
  export interface ScheduleHookContext extends BaseContext {
365
385
  /** The run's local cache handle — its `dbHandle()` holds the run history a policy can learn from. */
366
386
  readonly localCache: Cache
387
+ /** The run's branch (`currentBranch`), the timings a forecast reads first: pass it to `LocalHistoryProvider`. */
388
+ readonly branch: string | null
367
389
  }
368
390
 
391
+ /** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
369
392
  export interface CacheContext extends BaseContext {
370
- /** The local Cache handle the plugin may wrap (LayeredCache(local, remote)). */
371
- readonly localCache: Cache
372
- /** The run's cache policy (the 4 read/write axes). */
373
- readonly policy: CachePolicy
393
+ /**
394
+ * The run's `cache.scope` (`'pr-123'`), `undefined` when none. Core already keys a scoped run's
395
+ * writes apart; a remote reads it to pick a credential or check a token's
396
+ * claims (an OIDC branch) for every call it makes.
397
+ */
398
+ readonly scope: string | undefined
399
+ }
400
+
401
+ export interface OutputContext extends BaseContext {
402
+ /** Core would write to a terminal: stdout (stderr under `json`) is a TTY. */
403
+ readonly isTTY: boolean
404
+ /** The run is on CI (`CI` set, or a provider core detects). */
405
+ readonly ci: boolean
406
+ /** `--format json`: stdout carries the machine-readable result. */
407
+ readonly json: boolean
374
408
  }
375
409
 
376
410
  export interface ExecutorContext extends BaseContext {