@vzn/vx 0.0.641 → 0.0.643

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 (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
@@ -39,7 +39,9 @@ export interface LockfileClaimOptions {
39
39
  * the lockfile does not pin (bun.lock's `patchedDependencies` names a patch
40
40
  * file and records no hash of it). Their content hashes ('' when absent)
41
41
  * reach `digest` as `files` and join the memo's identity, so an edited
42
- * patch moves the key (item 1014). Called only when the memo misses.
42
+ * patch moves the key (item 1014). A directory (a `file:` dependency the
43
+ * install copies) hashes every file under it but `node_modules` and
44
+ * `.git`. Called only when the memo misses.
43
45
  */
44
46
  readonly extraFiles?: (text: string) => readonly string[]
45
47
  /** Folded into the memo's identity: bump when `digest` folds differently. */
@@ -109,13 +111,33 @@ interface Memo {
109
111
  /** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
110
112
  async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
111
113
  if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return 'outside-root'
114
+ const abs = path.join(workspaceRoot, rel)
112
115
  try {
113
- return xxh3hex(await Bun.file(path.join(workspaceRoot, rel)).bytes())
114
- } catch {
116
+ return xxh3hex(await Bun.file(abs).bytes())
117
+ } catch (err) {
118
+ if ((err as NodeJS.ErrnoException).code === 'EISDIR') return hashDir(abs)
115
119
  return ''
116
120
  }
117
121
  }
118
122
 
123
+ /**
124
+ * Every file under `dir` but its `node_modules` and `.git`, as `rel\0hash`
125
+ * lines in sorted order. Links are followed: a linked file is what a task
126
+ * reads, and the scan skipped it, so an edit to its target moved no key.
127
+ */
128
+ async function hashDir(dir: string): Promise<string> {
129
+ const rels: string[] = []
130
+ const scan = { cwd: dir, dot: true, onlyFiles: true, followSymlinks: true }
131
+ for await (const rel of new Bun.Glob('**').scan(scan)) {
132
+ if (!rel.split('/').some((s) => s === 'node_modules' || s === '.git')) rels.push(rel)
133
+ }
134
+ rels.sort()
135
+ const lines = await Promise.all(
136
+ rels.map(async (rel) => `${rel}\0${xxh3hex(await Bun.file(path.join(dir, rel)).bytes())}`),
137
+ )
138
+ return `dir\0${xxh3hex(lines.join('\n'))}`
139
+ }
140
+
119
141
  /**
120
142
  * A file's identity for the in-process gate, as `Cache.hashFile`'s memo
121
143
  * keys one: size and mtime alone kept the digests of a lockfile or patch
@@ -126,9 +148,16 @@ function identityOf(st: { size: number; mtimeMs: number; ctimeMs: number; ino: n
126
148
  return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`
127
149
  }
128
150
 
151
+ // A directory's own stat moves only when an entry is added or removed, not
152
+ // when a file in it is edited: its identity is its content hash.
129
153
  async function statsOf(workspaceRoot: string, rels: readonly string[]): Promise<string[]> {
130
154
  return Promise.all(
131
- rels.map((rel) => stat(path.join(workspaceRoot, rel)).then(identityOf, () => 'gone')),
155
+ rels.map((rel) =>
156
+ stat(path.join(workspaceRoot, rel)).then(
157
+ (st) => (st.isDirectory() ? hashExtra(workspaceRoot, rel) : identityOf(st)),
158
+ () => 'gone',
159
+ ),
160
+ ),
132
161
  )
133
162
  }
134
163
 
@@ -262,15 +291,29 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
262
291
  // lockfile is broken" and "the base commit's is" — a migration
263
292
  // commit hits the second, and the bare parser message named
264
293
  // neither (2026-09-20).
265
- const sideOf = (bytes: Uint8Array, side: string): ReadonlyMap<string, string> => {
294
+ // A changed extra file is folded by its content on each side; one
295
+ // left out is the same on both, so it moves no digest either way.
296
+ const extrasOf = (pick: 'before' | 'after'): Map<string, string> => {
297
+ const out = new Map<string, string>()
298
+ for (const [rel, sides] of change.extras ?? []) {
299
+ const bytes = sides[pick]
300
+ out.set(rel, bytes === null ? '' : xxh3hex(bytes))
301
+ }
302
+ return out
303
+ }
304
+ const sideOf = (
305
+ bytes: Uint8Array,
306
+ pick: 'before' | 'after',
307
+ side: string,
308
+ ): ReadonlyMap<string, string> => {
266
309
  try {
267
- return digest(decode(bytes), new Map())
310
+ return digest(decode(bytes), extrasOf(pick))
268
311
  } catch (err) {
269
312
  throw new Error(`${err instanceof Error ? err.message : String(err)} (${side})`)
270
313
  }
271
314
  }
272
- const before = sideOf(change.before, `as of the base ref`)
273
- const after = sideOf(change.after, `in the working tree`)
315
+ const before = sideOf(change.before, 'before', `as of the base ref`)
316
+ const after = sideOf(change.after, 'after', `in the working tree`)
274
317
  const names: string[] = []
275
318
  for (const p of ctx.projects) {
276
319
  const importer = importerOf(ctx.workspaceRoot, p.dir)
@@ -278,6 +321,9 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
278
321
  }
279
322
  return names
280
323
  },
324
+ ...(options.extraFiles !== undefined
325
+ ? { extraFiles: (_file: string, bytes: Uint8Array) => options.extraFiles!(decode(bytes)) }
326
+ : {}),
281
327
  },
282
328
  async key(task, ctx) {
283
329
  const digests = await loadOnce(ctx)
@@ -12,6 +12,7 @@ import path from 'node:path'
12
12
  import {
13
13
  type CacheLayer,
14
14
  type GitFilesCache,
15
+ resolveOutputTree,
15
16
  resolveOutputs,
16
17
  resolveWorkspaceOutputs,
17
18
  WORKSPACE_OUTPUT_PREFIX,
@@ -51,7 +52,9 @@ export function entryHolds(
51
52
  ): (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean {
52
53
  return (files, rows) => {
53
54
  const expected = new Set(
54
- rows.map((r) => r.path).filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
55
+ rows
56
+ .map((r) => r.path)
57
+ .filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX) && !p.endsWith('/')),
55
58
  )
56
59
  const seen = new Set(files)
57
60
  if ((node.addsToOutputsOf?.length ?? 0) > 0) return [...expected].every((p) => seen.has(p))
@@ -111,8 +114,6 @@ export interface SaveMissArgs {
111
114
  ownOutputFiles?: string[] | undefined
112
115
  /** `ownOutputFiles` for `cache.outputs.workspaceFiles` (A-43). */
113
116
  ownWsOutputFiles?: string[] | undefined
114
- /** A sandboxed task's reach: a symlinked output it could not read is refused (`sandboxReadable`). */
115
- readable?: ((real: string) => boolean) | undefined
116
117
  /** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
117
118
  captured: readonly TaskInputComponent[]
118
119
  command: string
@@ -129,7 +130,7 @@ export interface SaveMissArgs {
129
130
  * (a same-project downstream task reads both), and the pack + write +
130
131
  * index go to the run's save lane (save-lane.ts).
131
132
  */
132
- deferSave?: ((save: () => Promise<void>) => Promise<void>) | undefined
133
+ deferSave?: ((save: () => Promise<void>) => Promise<boolean>) | undefined
133
134
  /** Time the save and size its artifact, for a telemetry sink: one stat. */
134
135
  measure?: boolean | undefined
135
136
  }
@@ -138,6 +139,8 @@ export interface SaveMissArgs {
138
139
  export interface SaveFacts {
139
140
  saveMs?: number
140
141
  artifactBytes?: number
142
+ /** The entry landed: the outcome's `saved`, set only once it has. */
143
+ saved?: true
141
144
  }
142
145
 
143
146
  /**
@@ -147,13 +150,16 @@ export interface SaveFacts {
147
150
  export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveFacts> }> {
148
151
  const { node, cache, log } = a
149
152
  const endResolve = span('miss: resolve outputs')
150
- const outputFiles =
151
- a.ownOutputFiles ??
152
- (await resolveOutputs({
153
- projectDir: node.projectDir,
154
- outputs: a.outputs,
155
- nestedProjectDirs: a.nestedProjectDirs,
156
- }))
153
+ // An additive task owns only the files it wrote (item 588), no directory.
154
+ const tree =
155
+ a.ownOutputFiles !== undefined
156
+ ? { files: a.ownOutputFiles, emptyDirs: [] }
157
+ : await resolveOutputTree({
158
+ projectDir: node.projectDir,
159
+ outputs: a.outputs,
160
+ nestedProjectDirs: a.nestedProjectDirs,
161
+ })
162
+ const outputFiles = tree.files
157
163
  const wsOutputFiles =
158
164
  a.ownWsOutputFiles ??
159
165
  (await resolveWorkspaceOutputs({
@@ -204,11 +210,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
204
210
  hash: a.hash,
205
211
  projectDir: node.projectDir,
206
212
  outputFiles,
207
- ...(wsOutputFiles.length > 0
208
- ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
209
- : {}),
213
+ ...(tree.emptyDirs.length > 0 ? { outputDirs: tree.emptyDirs } : {}),
214
+ // The root an output symlink's target must stay inside, outputs.workspaceFiles or not.
215
+ workspaceRoot: a.workspaceRoot,
216
+ ...(wsOutputFiles.length > 0 ? { workspaceOutputFiles: wsOutputFiles } : {}),
210
217
  inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
211
- readable: a.readable,
212
218
  // No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
213
219
  // the contract no longer accepts one — so the invariant is enforced by
214
220
  // the type rather than by every call site remembering the gate.
@@ -258,9 +264,13 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
258
264
  // Off the slot when the run keeps a lane (the save lane bounds and
259
265
  // drains it); in the slot otherwise — an embedder without a lane gets
260
266
  // the entry before the outcome.
261
- if (a.deferSave !== undefined) return { landed: a.deferSave(save).then(() => facts) }
267
+ if (a.deferSave !== undefined) {
268
+ return {
269
+ landed: a.deferSave(save).then((stored) => (stored ? { ...facts, saved: true } : facts)),
270
+ }
271
+ }
262
272
  await save()
263
- return { landed: Promise.resolve(facts) }
273
+ return { landed: Promise.resolve({ ...facts, saved: true }) }
264
274
  }
265
275
 
266
276
  /** What `markUnsaved` reads of a miss: where its declared outputs are. */
@@ -90,8 +90,16 @@ export interface RunOptions {
90
90
  * saying it is not.
91
91
  */
92
92
  remoteRequested?: boolean
93
- /** This run's `cache.scope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `.vx/workspace`. */
93
+ /** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
94
94
  cacheScope?: string
95
+ /**
96
+ * What the branch changed, for a scoped run: the tasks whose key it can
97
+ * move read the scope's key, every other task the default key (one
98
+ * lookup each). Absent, the run diffs the branch from `scopeBase`.
99
+ */
100
+ scopeChanges?: AffectedChanges
101
+ /** The commit a scoped run diffs the branch from (`--affected`'s base); absent, a plugin's, else the merge base with the default branch. */
102
+ scopeBase?: string
95
103
  /**
96
104
  * The default policy's remote write is off (`remote:r`): the CLI passes it
97
105
  * off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
@@ -7,7 +7,15 @@ import type { Logger } from '../report/index.js'
7
7
  import type { OutputContext, VxPlugin } from './plugin.js'
8
8
 
9
9
  const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
10
- const OPTIONAL = ['runStart', 'taskStart', 'runEnd', 'stepStart', 'stepEnd', 'runDone'] as const
10
+ const OPTIONAL = [
11
+ 'runStart',
12
+ 'taskStart',
13
+ 'runEnd',
14
+ 'stepStart',
15
+ 'stepEnd',
16
+ 'runStop',
17
+ 'runDone',
18
+ ] as const
11
19
 
12
20
  /** Reject anything that is not Logger-shaped, naming what is missing. */
13
21
  function checkLogger(value: unknown): Logger {
@@ -146,6 +146,10 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
146
146
  const outcomes = await runGraph({
147
147
  nodes: args.nodes,
148
148
  concurrency: 1,
149
+ // A plan's keys and probes do not depend on the order it walks in, so
150
+ // it ranks nothing: the default ranking's closure over every task was
151
+ // ~30 ms of a 1,000-project `--dry`.
152
+ strategy: 'ready-order',
149
153
  execute: async (node, live) => {
150
154
  const upstream = keyUpstream(node, live)
151
155
  const hashArgs = {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import path from 'node:path'
11
11
  import {
12
+ type BranchScope,
12
13
  type Cache,
13
14
  type CacheLayer,
14
15
  type CachePolicy,
@@ -34,7 +35,7 @@ import type {
34
35
  RunReport,
35
36
  ScheduleHookContext,
36
37
  VxPlugin,
37
- WorkspaceHookContext,
38
+ ConfigContext,
38
39
  } from './plugin.js'
39
40
  import type { ProjectMeta } from '../workspace/index.js'
40
41
 
@@ -137,7 +138,7 @@ function assertEditedInPlace(
137
138
  export async function applyConfigHooks(
138
139
  plugins: readonly VxPlugin[],
139
140
  workspace: WorkspaceConfig,
140
- ctx: WorkspaceHookContext,
141
+ ctx: ConfigContext,
141
142
  /** Runs after EACH plugin's edit, so a refusal can name the plugin that made it. */
142
143
  afterEach?: (plugin: VxPlugin) => void,
143
144
  ): Promise<void> {
@@ -381,6 +382,36 @@ export async function claimedAffected(
381
382
  return names
382
383
  }
383
384
 
385
+ /**
386
+ * The files a claimed file names that its plugin keys too
387
+ * (`FingerprintClaim.extraFiles`); `undefined` when it names none it can
388
+ * answer for, and a plugin that throws is refused by its name.
389
+ */
390
+ export function claimedExtraFiles(
391
+ plugin: VxPlugin,
392
+ file: string,
393
+ bytes: Uint8Array,
394
+ ): readonly string[] | undefined {
395
+ const read = plugin.fingerprint?.extraFiles
396
+ if (read === undefined) return undefined
397
+ let names: unknown
398
+ try {
399
+ names = read.call(plugin.fingerprint, file, bytes)
400
+ } catch (err) {
401
+ throw new UserError(
402
+ maskedLine(
403
+ `plugin '${plugin.name}' failed in fingerprint: ${err instanceof Error ? err.message : String(err)}`,
404
+ ),
405
+ )
406
+ }
407
+ if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
408
+ throw new UserError(
409
+ `plugin '${plugin.name}' failed in fingerprint: extraFiles returned ${describeValue(names)}, not a list of paths`,
410
+ )
411
+ }
412
+ return names as string[]
413
+ }
414
+
384
415
  /** What a run hands the `schedule` stage: a step per plugin, and where refined weights go. */
385
416
  export interface ScheduleRun {
386
417
  readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
@@ -525,6 +556,8 @@ export interface CacheHost extends CacheContext {
525
556
  readonly policy: CachePolicy
526
557
  /** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
527
558
  readonly remotes?: ResolvedRemote[]
559
+ /** The run's branch scope, which picks each remote key. */
560
+ readonly branch?: BranchScope
528
561
  }
529
562
 
530
563
  /**
@@ -539,7 +572,7 @@ export async function resolveCache(
539
572
  plugins: readonly VxPlugin[],
540
573
  host: CacheHost,
541
574
  ): Promise<CacheLayer> {
542
- const { localCache, policy, remotes, ...ctx } = host
575
+ const { localCache, policy, remotes, branch, ...ctx } = host
543
576
  const resolved: ResolvedRemote[] = []
544
577
  for (const plugin of plugins) {
545
578
  if (plugin.cache === undefined) continue
@@ -548,6 +581,7 @@ export async function resolveCache(
548
581
  assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
549
582
  const entry: ResolvedRemote = {
550
583
  remote,
584
+ plugin: plugin.name,
551
585
  read: policy.remoteRead,
552
586
  write: policy.remoteWrite,
553
587
  onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
@@ -555,7 +589,7 @@ export async function resolveCache(
555
589
  resolved.push(entry)
556
590
  remotes?.push(entry)
557
591
  }
558
- return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
592
+ return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved, branch)
559
593
  }
560
594
 
561
595
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */
@@ -20,7 +20,7 @@ import {
20
20
  import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
21
21
  import type { TaskExecutor } from '../exec/index.js'
22
22
  import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
23
- import { maskedLine, nearest, UserError } from '../util/index.js'
23
+ import { maskedLine, nearest, type PluginSecrets, SecretBroker, UserError } from '../util/index.js'
24
24
  import type { ProjectMeta } from '../workspace/index.js'
25
25
  import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
26
26
  import type { CiContext } from './run-context.js'
@@ -61,8 +61,10 @@ export interface VxPlugin {
61
61
  * The workspace config, before anything is derived from it (concurrency,
62
62
  * cacheDir, timeout, …). `plugins` is already fixed by the time this runs.
63
63
  * Core re-validates after each plugin, as it does a user's file.
64
+ * `ctx.setBranch` names what the CI knows of the branch: its cache scope
65
+ * and the commit it is compared with.
64
66
  */
65
- config?(workspace: WorkspaceConfig, ctx: WorkspaceHookContext): void | Promise<void>
67
+ config?(workspace: WorkspaceConfig, ctx: ConfigContext): void | Promise<void>
66
68
 
67
69
  /**
68
70
  * Projects beyond the directories holding a vx.config, their names, and
@@ -285,8 +287,12 @@ interface BaseContext {
285
287
  warn(message: string): void
286
288
  }
287
289
 
288
- /** What `setup` receives: the run's lifecycle, observe-only. */
289
- export interface PluginSetupContext extends BaseContext {
290
+ /**
291
+ * What `setup` receives: the run's lifecycle, observe-only, and the
292
+ * plugin's keychain (`secret`, `forgetSecret`): setup runs before the
293
+ * status line, so a prompt there is seen.
294
+ */
295
+ export interface PluginSetupContext extends BaseContext, PluginSecrets {
290
296
  /** This plugin's entries in the workspace's cache (`PluginCache`). */
291
297
  readonly cache: PluginCache
292
298
  /** The run is on CI (`CI` set, or a provider core detects). */
@@ -336,6 +342,22 @@ export interface WorkspaceHookContext {
336
342
  warn(message: string): void
337
343
  }
338
344
 
345
+ /**
346
+ * What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
347
+ * cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
348
+ * beats the one git names), and `base`, the commit the branch is compared
349
+ * with (a pull request's base), for a bare `--affected` and for which
350
+ * tasks read the scope. A later plugin's field wins.
351
+ */
352
+ export interface BranchFacts {
353
+ scope?: string
354
+ base?: string
355
+ }
356
+
357
+ export interface ConfigContext extends WorkspaceHookContext {
358
+ setBranch(facts: BranchFacts): void
359
+ }
360
+
339
361
  export interface DiscoverContext extends WorkspaceHookContext {
340
362
  /** Where vx's cache lives: a plugin may keep what it read to answer here. */
341
363
  readonly cacheDir: string
@@ -419,6 +441,15 @@ export interface FingerprintClaim {
419
441
  change: FingerprintChange,
420
442
  ctx: FingerprintContext,
421
443
  ): Iterable<string> | undefined | Promise<Iterable<string> | undefined>
444
+ /**
445
+ * The workspace-relative files a claimed file's bytes name that the
446
+ * plugin's key reads too: `bun.lock`'s patch files, which it names by
447
+ * path and hashes nowhere, or a `file:` dependency's directory. A change
448
+ * to one, or under one, asks `affected` about the file naming it, with
449
+ * `change.extras`. Without it, a `bun.lock` patch edit selects every
450
+ * project.
451
+ */
452
+ extraFiles?(file: string, bytes: Uint8Array): readonly string[]
422
453
  }
423
454
 
424
455
  export interface FingerprintChange {
@@ -426,6 +457,15 @@ export interface FingerprintChange {
426
457
  readonly file: string
427
458
  readonly before: Uint8Array | null
428
459
  readonly after: Uint8Array | null
460
+ /**
461
+ * Each changed `extraFiles` entry, as the claimant names it, at the base
462
+ * ref and in the working tree. A directory's sides are not its bytes:
463
+ * they differ because a file under it changed.
464
+ */
465
+ readonly extras?: ReadonlyMap<
466
+ string,
467
+ { readonly before: Uint8Array | null; readonly after: Uint8Array | null }
468
+ >
429
469
  }
430
470
 
431
471
  export interface FingerprintContext extends BaseContext {
@@ -463,7 +503,7 @@ export interface ScheduleHookContext extends BaseContext {
463
503
  /** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
464
504
  export interface CacheContext extends BaseContext {
465
505
  /**
466
- * The run's `cache.scope` (`'pr-123'`), `undefined` when none. Core already keys a scoped run's
506
+ * The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none. Core already keys a scoped run's
467
507
  * writes apart; a remote reads it to pick a credential or check a token's
468
508
  * claims (an OIDC branch) for every call it makes.
469
509
  */
@@ -695,6 +735,8 @@ export interface InstallPluginsArgs {
695
735
  ci?: boolean
696
736
  /** Default null. */
697
737
  branch?: string | null
738
+ /** The run's secrets; default: the OS keychain and this terminal. */
739
+ secrets?: SecretBroker
698
740
  /** Each setup as the step `<plugin>:setup`; default: untimed. */
699
741
  step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
700
742
  }
@@ -726,6 +768,7 @@ export class PluginSetupError extends UserError {
726
768
  export async function installPlugins(args: InstallPluginsArgs): Promise<() => void> {
727
769
  const { plugins, bus, workspaceRoot, cacheDir } = args
728
770
  const warn = args.warn ?? ((m) => console.error(m))
771
+ const secrets = args.secrets ?? new SecretBroker(args.ci ?? false, warn)
729
772
  const disposers: Array<() => void> = []
730
773
  // By object, not name: one package can export two plugins.
731
774
  const disabled = new Set<Plugin>()
@@ -754,6 +797,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
754
797
  cacheDir,
755
798
  warn,
756
799
  cache: args.cache?.(plugin.name) ?? absentPluginCache,
800
+ ...secrets.for(plugin.name),
757
801
  ci: args.ci ?? false,
758
802
  branch: args.branch ?? null,
759
803
  // A subscription made past the hooks leaves with the run like a