@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,64 +1,172 @@
1
- // Plugin API — in-process extensions on top of the run event bus.
1
+ // Plugin API — the ONE `VxPlugin` contract (docs/design/pipeline-2026-09.md).
2
2
  //
3
- // Collapses what `architecture-review-2026-06.md §4.1` calls the
4
- // in-process subscriber + the WS subscriber into ONE Plugin contract.
5
- // Users register plugins via defineWorkspace({ plugins }); each plugin
6
- // receives a PluginContext with the bus + workspace metadata and can
7
- // install lifecycle hooks. Plugins observe; they do not redirect.
3
+ // Users register plugins via defineWorkspace({ plugins }). Behaviour
4
+ // capabilities (`cache`, `executor`) return objects core calls INTO;
5
+ // observe capabilities (`telemetry`, `setup` on the bus) can only read.
8
6
  //
9
- // Crash isolation: a plugin that throws on setup() is wrapped in a
10
- // UserError-style failure message naming the plugin (clean abort, no
11
- // stack), matching the existing 'broken vx.config.ts' error style. A
12
- // plugin that throws inside a hook is logged + the plugin is disabled
13
- // for the remainder of the run (mirrors the deleted Observer's
14
- // makeSafeObserver pattern; surfaces are isolated from execution).
15
- //
16
- // Hook return values: today every hook is fire-and-forget (the bus IS
17
- // the message pipe; hooks just give users an ergonomic place to read
18
- // it). One write-capable hook is reserved for a future iteration —
19
- // onCacheLookup returning { skip: true } — but not in v1.
7
+ // Crash isolation: a plugin that throws in a load-bearing hook aborts the
8
+ // run with a clean UserError naming plugin + hook. A plugin that throws
9
+ // inside a bus hook is logged and disabled for the remainder of the run.
20
10
 
11
+ import { readFileSync } from 'node:fs'
12
+ import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
21
14
  import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
15
+ import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
16
+ import type { TaskExecutor } from '../exec/index.js'
22
17
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
23
18
  import { UserError } from '../util/index.js'
24
- import type { EventBus, RunStartInfo, WireEvent } from './events.js'
25
- import type { RunBackend, RunRequest } from './protocol.js'
19
+ import type { ProjectMeta } from '../workspace/index.js'
20
+ import type { EventBus, RunStartInfo } from './events.js'
26
21
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
27
22
 
28
23
  /**
29
- * A vx plugin. Contributes any subset of three RUN-LEVEL infrastructure
30
- * capabilities — where work routes (backend), which cache is used (cache),
31
- * who observes the run (eventSink). It NEVER changes how a task executes
32
- * (Architecture principle #3: shell is the API). Registered explicitly in
33
- * vx.workspace.ts via defineWorkspace({ plugins: [...] }). No auto-discovery.
24
+ * A vx plugin. Contributes any subset of the run-level capabilities —
25
+ * where work runs (executor), which cache is used (cache), who
26
+ * observes the run (telemetry). It never changes WHAT a task is (the
27
+ * command string — principle #3), only where and how that command is
28
+ * executed. Registered explicitly in vx.workspace.ts via
29
+ * defineWorkspace({ plugins: [...] }). No auto-discovery.
34
30
  *
35
31
  * The old observe-only `Plugin` (`{ name, setup(ctx) }`) is a subset of
36
32
  * this shape: a plugin with only `setup` installs and runs exactly as
37
- * before via `installPlugins`. The new capabilities are consulted by
38
- * `plugin-host.ts`, each falling back to today's default.
33
+ * before via `installPlugins`. The capabilities are consulted by
34
+ * `plugin-host.ts`; core's own executor and cache are plugins too
35
+ * (src/plugins/), declared by the workspace — there is no fallback
36
+ * outside the list.
39
37
  */
40
38
  export interface VxPlugin {
41
- /** Stable identifier, convention `'org/name'`. Used in errors + precedence logs. */
39
+ /**
40
+ * The name of the package the plugin was defined in — set by
41
+ * `definePlugin` from the nearest `package.json`, never by the plugin.
42
+ * Heads every warning about the plugin, every `vx info` line, and the
43
+ * key material a `key` hook contributes.
44
+ */
42
45
  readonly name: string
43
46
 
44
- // --- BEHAVIOR capabilities (change WHAT/HOW work runs — opt-in) -----------
47
+ // --- PIPELINE stages (shape the run before it executes — opt-in) ----------
48
+ //
49
+ // Each receives the object core is about to use and edits it IN PLACE;
50
+ // core re-validates after the last plugin, so a plugin cannot produce what
51
+ // the loader would refuse from a user. Declaration order is the order.
52
+ // Whatever a stage changes reaches the cache key by construction: the key
53
+ // hashes the task config AFTER `project` ran. See
54
+ // docs/design/pipeline-2026-09.md.
55
+
56
+ /**
57
+ * The workspace config, before anything is derived from it (concurrency,
58
+ * cacheDir, timeout, …). `plugins` is already fixed by the time this runs.
59
+ */
60
+ config?(workspace: WorkspaceConfig, ctx: WorkspaceHookContext): void | Promise<void>
61
+
62
+ /**
63
+ * One project's validated config, right after it loaded and before the
64
+ * graph is built. Add, remove or edit tasks. Runs for every loaded project
65
+ * on every run — a config's cached evaluation is the user's file, and the
66
+ * hook is applied on top of it live. A package with NO config file is
67
+ * loaded too, as `{ tasks: {} }`, so a plugin can give tasks to packages
68
+ * that never wrote one (`ctx.packageJson` carries their scripts); with no
69
+ * `project` plugin such a package is never visited.
70
+ */
71
+ project?(config: ProjectConfig, ctx: ProjectHookContext): void | Promise<void>
72
+
73
+ /**
74
+ * The task graph, after `dependsOn` expansion and before scheduling. Add
75
+ * or drop edges (`node.deps`), mark tasks requested, adjust resources. A
76
+ * dep naming a task that is not in the graph, or a cycle, is refused with
77
+ * the plugin's name.
78
+ */
79
+ graph?(nodes: Map<string, TaskNode>, ctx: GraphHookContext): void | Promise<void>
80
+
81
+ /**
82
+ * Extra cache-key material for one task: a `{ name: value }` record that
83
+ * is folded into the key (and named in `vx why`) — a tool version, a
84
+ * feature flag, anything the declared inputs cannot see. Runs once per
85
+ * task per run, before any key is derived. Return undefined to add
86
+ * nothing. Values must be deterministic for the same inputs or the key
87
+ * never hits.
88
+ */
89
+ key?(
90
+ task: TaskNode,
91
+ ctx: KeyHookContext,
92
+ ):
93
+ | Readonly<Record<string, string>>
94
+ | undefined
95
+ | Promise<Readonly<Record<string, string>> | undefined>
96
+
97
+ /**
98
+ * Workspace-root files this plugin keys on its own, taken OUT of the
99
+ * workspace fingerprint. Core folds every lockfile it knows into every
100
+ * task's key, so one `pnpm install` re-keys the whole workspace. A plugin
101
+ * that reads the lockfile and folds each project's own dependency
102
+ * closure through `key` claims the file here: core leaves it out of the
103
+ * fingerprint every key sees (the config-evaluation cache still keys on
104
+ * it — a config may import a dependency), and `--affected` asks
105
+ * `affected` which projects a change to it touches instead of selecting
106
+ * every project. A file has one claimant; a name core does not fold is
107
+ * refused, since there is nothing to take out.
108
+ */
109
+ readonly fingerprint?: FingerprintClaim
110
+
111
+ /**
112
+ * Scheduling priorities: task id → weight, higher runs first among READY
113
+ * tasks (merged over the structural baseline, which stays the tie-break).
114
+ * Runs once, after the graph is final. A later plugin's weight for a task
115
+ * overrides an earlier one's. Return undefined to leave the baseline.
116
+ */
117
+ schedule?(
118
+ nodes: ReadonlyMap<string, TaskNode>,
119
+ ctx: ScheduleHookContext,
120
+ ): ReadonlyMap<string, number> | undefined | Promise<ReadonlyMap<string, number> | undefined>
45
121
 
46
122
  /**
47
- * Contribute a run backend. Returns a RunBackend (run(request) → result),
48
- * or undefined to decline (core then tries the next plugin, else the
49
- * fallback). Consulted ONCE per run, before scheduling. At most one
50
- * plugin's backend is used (first non-undefined, in declaration order).
123
+ * Admission over the worker count: asked for every task about to start
124
+ * on this machine, with the tasks running here right now. Return `false`
125
+ * to hold it until something finishes (it is asked again then). Asked
126
+ * many times per run, so it must be cheap and synchronous; a throw is
127
+ * reported once and the plugin admits from then on — a policy never
128
+ * breaks a run. Restore-tier hits and tasks on an executor pool hold no
129
+ * local resources and are never asked. When several plugins answer, all
130
+ * must admit. Core keeps no notion of what a task needs: a plugin that
131
+ * packs memory or CPU learns or declares the numbers itself
132
+ * (`@vzn/vx-schedule-history` packs what past executions used).
51
133
  */
52
- backend?(ctx: BackendContext): RunBackend | undefined | Promise<RunBackend | undefined>
134
+ admit?(task: TaskNode, ctx: AdmitContext): boolean
135
+
136
+ // --- CLI verbs (opt-in) ---------------------------------------------------
137
+
138
+ /**
139
+ * Verbs this plugin adds to the `vx` CLI, keyed by name. Consulted only
140
+ * for a verb core does not know — core's own verbs always win — and only
141
+ * when the cwd is inside a workspace that declares the plugin. `vx help`
142
+ * lists them under "Plugin commands". A verb's exit code is the process
143
+ * exit code; a thrown `UserError` prints cleanly, like core's own.
144
+ */
145
+ readonly commands?: Readonly<Record<string, PluginCommand>>
146
+
147
+ // --- BEHAVIOR capabilities (change WHAT/HOW work runs — opt-in) -----------
53
148
 
54
149
  /**
55
150
  * Contribute a cache layer. Returns a CacheLayer wrapping (or replacing)
56
151
  * the local Cache, or undefined to decline. Consulted ONCE per prepareRun.
57
- * Precedence: first non-undefined plugin cache wins; else core's env-var
58
- * Turbo-wire LayeredCache; else the bare local Cache.
152
+ * Precedence: first non-undefined plugin cache wins, in declaration
153
+ * order, ahead of core's own `.vx/cache` handle at the tail. A layer
154
+ * that WRAPS the local handle subsumes that tail, so it is not written
155
+ * twice.
59
156
  */
60
157
  cache?(ctx: CacheContext): CacheLayer | undefined | Promise<CacheLayer | undefined>
61
158
 
159
+ /**
160
+ * Contribute a task executor — WHERE one task's command runs. Consulted
161
+ * ONCE per run; every contributed executor is kept, in declaration order,
162
+ * and per task the first whose `accepts()` passes executes it. Nothing is
163
+ * appended in front: core's own local executor is always the TAIL, so
164
+ * declining a task hands it back to this machine rather than failing
165
+ * the run. Persistent tasks never reach an executor (local by
166
+ * construction).
167
+ */
168
+ executor?(ctx: ExecutorContext): TaskExecutor | undefined | Promise<TaskExecutor | undefined>
169
+
62
170
  // --- OBSERVE-ONLY capability (cannot change behavior — by construction) ---
63
171
 
64
172
  /**
@@ -78,15 +186,6 @@ export interface VxPlugin {
78
186
  | undefined
79
187
  | Promise<TelemetrySink | TelemetrySink[] | undefined>
80
188
 
81
- /**
82
- * @deprecated Prefer `telemetry`. Contribute an event sink — a consumer of
83
- * the serializable WireEvent stream, subscribed for the whole run via
84
- * wireForwarder. Fire-and-forget; a throwing sink is isolated and cannot
85
- * break the run. Kept as a back-compat path; `telemetry` is the canonical,
86
- * analytics-shaped export contract.
87
- */
88
- eventSink?(ctx: EventSinkContext): EventSink | undefined | Promise<EventSink | undefined>
89
-
90
189
  /**
91
190
  * Optional one-time setup before any capability is consulted (validate the
92
191
  * workspace, open a connection, read a token). Throwing aborts the run with
@@ -98,12 +197,6 @@ export interface VxPlugin {
98
197
  teardown?(): void | Promise<void>
99
198
  }
100
199
 
101
- /** A WireEvent consumer. onEvent is fire-and-forget; flush is awaited at teardown. */
102
- export interface EventSink {
103
- onEvent(event: WireEvent): void
104
- flush?(): Promise<void>
105
- }
106
-
107
200
  /** Shared, read-only context every capability factory receives. */
108
201
  interface BaseContext {
109
202
  readonly workspaceRoot: string
@@ -113,11 +206,89 @@ interface BaseContext {
113
206
  }
114
207
 
115
208
  export interface PluginSetupContext extends BaseContext {}
116
- export interface EventSinkContext extends BaseContext {}
117
209
 
118
- export interface BackendContext extends BaseContext {
119
- /** The resolved RunRequest about to be executed — cwd, tasks, policy, flow. */
120
- readonly request: RunRequest
210
+ /** One CLI verb contributed by a plugin. */
211
+ export interface PluginCommand {
212
+ /** One line for `vx help`. */
213
+ readonly description: string
214
+ run(argv: readonly string[], ctx: CommandContext): number | Promise<number>
215
+ }
216
+
217
+ export interface CommandContext extends BaseContext {}
218
+
219
+ /** `config` runs before the cache dir is known — it may be what the hook changes. */
220
+ export interface WorkspaceHookContext {
221
+ readonly workspaceRoot: string
222
+ warn(message: string): void
223
+ }
224
+
225
+ export interface ProjectHookContext extends BaseContext {
226
+ /** The package name (`package.json#name`). */
227
+ readonly name: string
228
+ /** The project's directory, absolute. */
229
+ readonly dir: string
230
+ /** The parsed `package.json`, read-only. */
231
+ readonly packageJson: Readonly<Record<string, unknown>>
232
+ /**
233
+ * Every package the workspace discovered — the one being visited, the
234
+ * rest of the run's scope and the packages outside it, config file or
235
+ * not — as core read them at startup. A plugin whose mapping needs the
236
+ * whole workspace (a `dependsOn` is only valid against every package's
237
+ * scripts at once) reads it here instead of walking the workspace a
238
+ * second time. The same array is handed to every visit of a run and a
239
+ * new one to the next run — a plugin instance outlives a run under `vx
240
+ * watch`, so a per-run memo keys on this array's identity, never on the
241
+ * process.
242
+ */
243
+ readonly projects: readonly ProjectMeta[]
244
+ }
245
+
246
+ export interface GraphHookContext extends BaseContext {
247
+ /** Task ids the user asked for (the rest were pulled in by `dependsOn`). */
248
+ readonly requested: readonly string[]
249
+ }
250
+
251
+ export interface KeyHookContext extends BaseContext {}
252
+
253
+ /** A plugin's claim on workspace fingerprint files — see `VxPlugin.fingerprint`. */
254
+ export interface FingerprintClaim {
255
+ /** Root-relative names from `WORKSPACE_FINGERPRINT_FILES`, e.g. `['pnpm-lock.yaml']`. */
256
+ readonly files: readonly string[]
257
+ /**
258
+ * The projects (package names) a change to a claimed file affects. Called
259
+ * by `--affected` with the file's bytes at the base ref and in the
260
+ * working tree (`null` where it does not exist). Return `undefined` to
261
+ * select every project — the answer a claimant gives when it cannot
262
+ * tell, and what core does for an unclaimed file.
263
+ */
264
+ affected(
265
+ change: FingerprintChange,
266
+ ctx: FingerprintContext,
267
+ ): Iterable<string> | undefined | Promise<Iterable<string> | undefined>
268
+ }
269
+
270
+ export interface FingerprintChange {
271
+ /** The claimed file's root-relative name. */
272
+ readonly file: string
273
+ readonly before: Uint8Array | null
274
+ readonly after: Uint8Array | null
275
+ }
276
+
277
+ export interface FingerprintContext extends BaseContext {
278
+ /** Every project in the workspace: package name and absolute directory. */
279
+ readonly projects: ReadonlyArray<{ readonly name: string; readonly dir: string }>
280
+ }
281
+
282
+ export interface AdmitContext {
283
+ /** The tasks executing on this machine right now, in dispatch order. */
284
+ readonly running: readonly TaskNode[]
285
+ /** The run's worker count — the ceiling the count gate already applies. */
286
+ readonly concurrency: number
287
+ }
288
+
289
+ export interface ScheduleHookContext extends BaseContext {
290
+ /** The run's local cache handle — its `dbHandle()` holds the run history a policy can learn from. */
291
+ readonly localCache: Cache
121
292
  }
122
293
 
123
294
  export interface CacheContext extends BaseContext {
@@ -127,6 +298,11 @@ export interface CacheContext extends BaseContext {
127
298
  readonly policy: CachePolicy
128
299
  }
129
300
 
301
+ export interface ExecutorContext extends BaseContext {
302
+ /** The run's worker count — an executor that paces itself reads it here. */
303
+ readonly concurrency: number
304
+ }
305
+
130
306
  export interface PluginContext {
131
307
  /** Where the workspace lives on disk. */
132
308
  readonly workspaceRoot: string
@@ -170,12 +346,76 @@ export interface Plugin {
170
346
  * be installed synchronously inside setup() so no events are missed.
171
347
  *
172
348
  * OPTIONAL: a capability-only plugin (one that contributes
173
- * `backend`/`cache`/`eventSink` but no `setup`) is simply skipped by
349
+ * `cache`/`executor`/`telemetry` but no `setup`) is simply skipped by
174
350
  * `installPlugins` — its capabilities are consulted by `plugin-host.ts`.
175
351
  */
176
352
  setup?(ctx: PluginContext): void | Promise<void>
177
353
  }
178
354
 
355
+ /** What a plugin author writes: every hook, and no name. */
356
+ export type PluginHooks = Omit<VxPlugin, 'name'>
357
+
358
+ /**
359
+ * Where a plugin is defined — `import.meta` of its module. `dir` is Bun's
360
+ * field; `url` is the standard one, for a module evaluated elsewhere.
361
+ */
362
+ export interface PluginOrigin {
363
+ readonly dir?: string
364
+ readonly url?: string
365
+ }
366
+
367
+ const packageNameByDir = new Map<string, string>()
368
+
369
+ /** The name of the nearest `package.json` above `dir` — the one that owns it. */
370
+ function pluginPackageName(dir: string): string {
371
+ const memo = packageNameByDir.get(dir)
372
+ if (memo !== undefined) return memo
373
+ for (let d = dir; ;) {
374
+ let text: string | undefined
375
+ try {
376
+ text = readFileSync(path.join(d, 'package.json'), 'utf8')
377
+ } catch {
378
+ /* not here; look one level up */
379
+ }
380
+ if (text !== undefined) {
381
+ const name = (JSON.parse(text) as { name?: unknown }).name
382
+ if (typeof name !== 'string' || name.length === 0) {
383
+ throw new UserError(
384
+ `definePlugin: ${path.join(d, 'package.json')} has no name — a plugin is a package, and its name is the package's`,
385
+ )
386
+ }
387
+ packageNameByDir.set(dir, name)
388
+ return name
389
+ }
390
+ const parent = path.dirname(d)
391
+ if (parent === d) {
392
+ throw new UserError(
393
+ `definePlugin: no package.json above ${dir} — a plugin is a package, and its name is the package's`,
394
+ )
395
+ }
396
+ d = parent
397
+ }
398
+ }
399
+
400
+ /**
401
+ * The one way to make a plugin: `definePlugin(import.meta, { ...hooks })`.
402
+ * The name is read from the package the calling module belongs to — the
403
+ * nearest `package.json` above it — and stamped where the workspace loader
404
+ * checks for it, so a plugin cannot be named anything but its package.
405
+ */
406
+ export function definePlugin(origin: PluginOrigin, hooks: PluginHooks): VxPlugin {
407
+ if ('name' in hooks) {
408
+ throw new UserError(`definePlugin: a plugin's name is its package name — drop the 'name' field`)
409
+ }
410
+ const dir =
411
+ origin.dir ?? (origin.url !== undefined ? path.dirname(fileURLToPath(origin.url)) : undefined)
412
+ if (dir === undefined) {
413
+ throw new UserError(`definePlugin: the first argument must be the plugin module's import.meta`)
414
+ }
415
+ const name = pluginPackageName(dir)
416
+ return { ...hooks, name, [PLUGIN_PACKAGE]: name } as VxPlugin
417
+ }
418
+
179
419
  export interface InstallPluginsArgs {
180
420
  plugins: readonly Plugin[]
181
421
  workspaceRoot: string
@@ -195,8 +435,8 @@ export interface InstallPluginsArgs {
195
435
  * hooks are subscribed before the next plugin's setup runs. Throws if any
196
436
  * plugin's setup() throws (the run cannot start with a broken plugin).
197
437
  *
198
- * A plugin without a `setup` is a capability-only plugin (backend / cache
199
- * / eventSink) — skipped here; those capabilities are consulted by
438
+ * A plugin without a `setup` is a capability-only plugin (cache
439
+ * / executor / telemetry) — skipped here; those capabilities are consulted by
200
440
  * `plugin-host.ts`.
201
441
  */
202
442
  export async function installPlugins(args: InstallPluginsArgs): Promise<() => void> {
@@ -209,7 +449,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
209
449
  if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
210
450
  throw new UserError('plugin missing `name` field')
211
451
  }
212
- // No setup → a capability-only plugin (backend / cache / eventSink),
452
+ // No setup → a capability-only plugin (cache / executor / telemetry),
213
453
  // consulted by plugin-host.ts; skip the hook install. A setup that's
214
454
  // present but not callable is a real authoring error — reject it.
215
455
  if (plugin.setup === undefined) continue