@vzn/vx 0.0.639 → 0.0.640

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 (74) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +111 -83
  3. package/docs/cli.md +70 -40
  4. package/docs/execution.md +4 -4
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +235 -180
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/show.json +14 -14
  11. package/skills/vx/SKILL.md +4 -3
  12. package/src/cache/cache.ts +4 -1
  13. package/src/cache/file-hashes.ts +7 -12
  14. package/src/cache/fs-clock.ts +62 -0
  15. package/src/cache/git-inputs.ts +2 -2
  16. package/src/cache/index.ts +3 -0
  17. package/src/cache/inputs.ts +154 -83
  18. package/src/cache/key-fold.ts +23 -3
  19. package/src/cache/layer.ts +8 -7
  20. package/src/cache/linked-inputs.ts +87 -0
  21. package/src/cli/foreign-flags.ts +1 -1
  22. package/src/cli/init.ts +23 -11
  23. package/src/cli/last.ts +24 -1
  24. package/src/cli/plugin-templates.ts +2 -2
  25. package/src/cli/select.ts +16 -6
  26. package/src/cli/show.ts +9 -6
  27. package/src/cli/upgrade.ts +95 -19
  28. package/src/cli/watch-filter.ts +22 -6
  29. package/src/cli/watch-judge.ts +49 -5
  30. package/src/cli/watch-set.ts +18 -8
  31. package/src/cli/watch.ts +74 -2
  32. package/src/cli/why.ts +5 -3
  33. package/src/config.ts +19 -15
  34. package/src/exec/executor.ts +4 -4
  35. package/src/graph/dependency-spec.ts +2 -2
  36. package/src/graph/folds.ts +236 -0
  37. package/src/graph/index.ts +8 -1
  38. package/src/graph/scheduler.ts +41 -15
  39. package/src/graph/task-graph.ts +135 -73
  40. package/src/index.ts +1 -1
  41. package/src/orchestrator/affected-tasks.ts +20 -19
  42. package/src/orchestrator/download-policy.ts +16 -22
  43. package/src/orchestrator/excluded-keys.ts +4 -5
  44. package/src/orchestrator/execute-task.ts +29 -25
  45. package/src/orchestrator/index.ts +1 -0
  46. package/src/orchestrator/keyed-projects.ts +12 -51
  47. package/src/orchestrator/local-shortcircuit.ts +1 -1
  48. package/src/orchestrator/persistent-task.ts +3 -3
  49. package/src/orchestrator/placement.ts +2 -2
  50. package/src/orchestrator/plan.ts +1 -1
  51. package/src/orchestrator/plugin-host.ts +62 -22
  52. package/src/orchestrator/plugin.ts +12 -2
  53. package/src/orchestrator/prepare.ts +12 -5
  54. package/src/orchestrator/projects.ts +7 -10
  55. package/src/orchestrator/run-report.ts +124 -46
  56. package/src/orchestrator/run-steps.ts +57 -0
  57. package/src/orchestrator/run.ts +51 -21
  58. package/src/orchestrator/sandbox-fix.ts +1 -1
  59. package/src/orchestrator/sandbox-request.ts +2 -2
  60. package/src/orchestrator/stable-keys.ts +59 -26
  61. package/src/orchestrator/steps.ts +26 -6
  62. package/src/orchestrator/task-hash.ts +31 -9
  63. package/src/orchestrator/telemetry-host.ts +3 -3
  64. package/src/orchestrator/telemetry.ts +9 -4
  65. package/src/orchestrator/upstream.ts +4 -181
  66. package/src/report/framed-output.ts +1 -1
  67. package/src/report/status-line.ts +7 -3
  68. package/src/settings.ts +11 -17
  69. package/src/util/paths.ts +1 -1
  70. package/src/workspace/affected.ts +81 -2
  71. package/src/workspace/config-imports.ts +1 -1
  72. package/src/workspace/config-schema.ts +142 -143
  73. package/src/workspace/lockfile.ts +1 -1
  74. package/src/workspace/migration.ts +19 -3
package/src/cli/init.ts CHANGED
@@ -13,7 +13,7 @@ import { gitTop, workspaceFileWritten } from '../handoff.js'
13
13
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
14
  import { lockAfterMigration } from './lock.js'
15
15
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
16
- import { isCompiledBinary } from './upgrade.js'
16
+ import { isCompiledBinary, targetVersion } from './upgrade.js'
17
17
  import { VERSION } from '../version.js'
18
18
  import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
19
19
  import {
@@ -217,8 +217,11 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
217
217
  }
218
218
 
219
219
  /**
220
- * A repo with a package.json is `@vzn/vx-migrate`'s: the installed one,
221
- * else this version's through `bun x`. It finds the root itself, asks a
220
+ * A repo with a package.json is `@vzn/vx-migrate`'s, at the latest
221
+ * release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
222
+ * at its own version, so init always lands on latest with all of them
223
+ * together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
224
+ * pinned core back from 0.0.639). It finds the root itself, asks a
222
225
  * terminal native or keep in a Turbo or Nx repo, and installs what it writes.
223
226
  */
224
227
  async function migrate(dirs: readonly string[], args: InitArgs): Promise<number> {
@@ -229,21 +232,30 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
229
232
  args.mode !== undefined && `--${args.mode}`,
230
233
  args.json === true && '--format=json',
231
234
  ].filter((f): f is string => typeof f === 'string')
232
- return Bun.spawn(migrateCommand(dirs, flags), {
235
+ // An exact version: `bun x` may answer `@latest` from its cache.
236
+ const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
237
+ return Bun.spawn(migrateCommand(dirs, flags, latest), {
233
238
  stdio: ['inherit', 'inherit', 'inherit'],
234
239
  // A compiled vx is a Bun runtime only when told so.
235
240
  env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
236
241
  }).exited
237
242
  }
238
243
 
239
- export function migrateCommand(dirs: readonly string[], flags: readonly string[]): string[] {
240
- for (const d of dirs) {
241
- const installed = path.join(d, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
242
- if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
244
+ export function migrateCommand(
245
+ dirs: readonly string[],
246
+ flags: readonly string[],
247
+ latest: string,
248
+ running = VERSION,
249
+ ): string[] {
250
+ // A source checkout (0.0.0, which npm never had) runs the vx-migrate
251
+ // beside it; a release never runs an installed one.
252
+ if (running === '0.0.0') {
253
+ for (const d of dirs) {
254
+ const installed = path.join(d, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
255
+ if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
256
+ }
243
257
  }
244
- // A source checkout is 0.0.0, which npm never had.
245
- const version = VERSION === '0.0.0' ? 'latest' : VERSION
246
258
  // Bun's own install output ("Slow filesystem detected…") in a pnpm or npm
247
259
  // repo reads as a stranger speaking (Growth's first-run funnel F2).
248
- return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${version}`, ...flags]
260
+ return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${latest}`, ...flags]
249
261
  }
package/src/cli/last.ts CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  exitSignal,
23
23
  resolveRunId,
24
24
  runFailures,
25
+ type RunStep,
26
+ runSteps,
25
27
  shortRunId,
26
28
  taskLog,
27
29
  } from '../orchestrator/index.js'
@@ -328,6 +330,22 @@ function reasonParts(t: RunSummaryRow): string {
328
330
  /** Hit rows shown before the rest fold — the slowest restores. */
329
331
  const HITS_SHOWN = 16
330
332
 
333
+ /**
334
+ * The steps the run did not wait on (`vx:prefetch`), which its own output
335
+ * never showed (M, 2026-10-16): here for debugging, marked `internal`.
336
+ */
337
+ function formatStepRows(steps: readonly RunStep[]): string[] {
338
+ if (steps.length === 0) return []
339
+ const idW = Math.max(...steps.map((s) => s.id.length), 4)
340
+ return [
341
+ '',
342
+ ...steps.map(
343
+ (s) =>
344
+ ` ${(s.ok ? 'success' : 'failed').padEnd(17)} ${s.id.padEnd(idW)} ${fmtMs(s.durationMs).padStart(8)} internal`,
345
+ ),
346
+ ]
347
+ }
348
+
331
349
  /**
332
350
  * The per-task rows: failures first, then what executed or was skipped,
333
351
  * then cache hits. Hits are the noise of a warm run — 996 of a thousand
@@ -336,6 +354,7 @@ const HITS_SHOWN = 16
336
354
  * count; the ones shown are the slowest restores, the one thing a hit's
337
355
  * row tells. `--format json` lists every row.
338
356
  */
357
+
339
358
  export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
340
359
  if (tasks.length === 0) return []
341
360
  const failed = tasks.filter((t) => t.status === 'failed')
@@ -545,7 +564,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
545
564
  ...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
546
565
  }
547
566
  })
548
- process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
567
+ const steps = runSteps(cacheDir, inv.runId)
568
+ process.stdout.write(
569
+ `${JSON.stringify({ invocation: inv, tasks, ...(steps.length > 0 ? { steps } : {}) })}\n`,
570
+ )
549
571
  return 0
550
572
  }
551
573
 
@@ -563,6 +585,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
563
585
  `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
564
586
  )
565
587
  lines.push(...formatTaskRows(detail?.tasks ?? []))
588
+ lines.push(...formatStepRows(runSteps(cacheDir, inv.runId)))
566
589
  // The next command after reading a failed run, with the arguments the
567
590
  // run forwarded to the tasks that got them (a failure under `-- --shard
568
591
  // 2` is that shard's); a dependency never got them, so its line has none.
@@ -11,7 +11,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
11
11
  cache: {
12
12
  plugin:
13
13
  "// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
14
- test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
14
+ test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
15
15
  },
16
16
  commands: {
17
17
  plugin:
@@ -31,7 +31,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
31
31
  key: {
32
32
  plugin:
33
33
  "// `key`: extra cache-key material. The value of each named environment\n// variable is folded into every cached task's key and named in `vx why`,\n// so a change to it is a miss. An unset variable folds as ''.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function envKey(names: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n key() {\n return Object.fromEntries(names.map((n) => [n, process.env[n] ?? '']))\n },\n })\n}\n",
34
- test: "// A test for the example `key` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
34
+ test: "// A test for the example `key` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
35
35
  },
36
36
  project: {
37
37
  plugin:
package/src/cli/select.ts CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  import { resolveSetting } from '../settings.js'
52
52
 
53
53
  /**
54
- * The projects whose tasks declare a `cache.inputs.workspaceFiles` glob
54
+ * The projects whose tasks declare an `inputs.workspaceFiles` glob
55
55
  * matching a changed path, one inside another project included (item
56
56
  * 954): `--affected` selects them, since the glob is that task's input. Through the run
57
57
  * path's staged load, so a glob a `project` plugin gave a config-less
@@ -71,10 +71,8 @@ export async function workspaceGlobOwners(
71
71
  ): Promise<string[]> {
72
72
  const declaresMatch = (config: ProjectConfig): boolean => {
73
73
  for (const task of Object.values(config.tasks ?? {})) {
74
- const cache = task.cache
75
- if (cache === undefined) continue
76
- if (changed.some((rel) => declaresInput(cache, null, rel))) return true
77
- if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
74
+ if (changed.some((rel) => declaresInput(task, null, rel))) return true
75
+ if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
78
76
  }
79
77
  return false
80
78
  }
@@ -335,7 +333,19 @@ export async function resolveFilters(
335
333
  workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
336
334
  workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
337
335
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
338
- ...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
336
+ ...(git !== undefined
337
+ ? {
338
+ untracked: async () => (await git.start()).untracked,
339
+ // A scoped enumeration lists only its projects: null asks git.
340
+ links: async () => {
341
+ const g = await git.start()
342
+ if (!g.scopes.includes('.')) return null
343
+ const out: string[] = []
344
+ for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
345
+ return out
346
+ },
347
+ }
348
+ : {}),
339
349
  }
340
350
  let names: Set<string>
341
351
  if (f.raw === affected) {
package/src/cli/show.ts CHANGED
@@ -466,14 +466,17 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
466
466
  )
467
467
  }
468
468
  add('interactive', exec?.interactive === undefined ? undefined : exec.interactive ? 'yes' : 'no')
469
+ const inputs = task.inputs
470
+ if (inputs !== undefined) {
471
+ add('inputs.files', list(inputs.files))
472
+ add('inputs.workspaceFiles', list(inputs.workspaceFiles))
473
+ add('inputs.env', list(inputs.env))
474
+ add('inputs.tasks', list(inputs.tasks))
475
+ add('inputs.runtime', list(inputs.runtime))
476
+ add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
477
+ }
469
478
  const cache = task.cache
470
479
  if (cache !== undefined) {
471
- add('inputs.files', list(cache.inputs.files))
472
- add('inputs.workspaceFiles', list(cache.inputs.workspaceFiles))
473
- add('inputs.env', list(cache.inputs.env))
474
- add('inputs.tasks', list(cache.inputs.tasks))
475
- add('inputs.runtime', list(cache.inputs.runtime))
476
- add('inputs.workspaceRuntime', list(cache.inputs.workspaceRuntime))
477
480
  add('outputs.files', list(cache.outputs.files))
478
481
  add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
479
482
  }
@@ -112,13 +112,18 @@ export function versionSpec(tag: string | undefined): string {
112
112
  * as an internal error with a stack (a network namespace without a
113
113
  * route, 2026-09-16). One line, the host and the reason.
114
114
  */
115
- async function fetchOrRefuse(url: string, init: RequestInit, what: string): Promise<Response> {
115
+ async function fetchOrRefuse(
116
+ url: string,
117
+ init: RequestInit,
118
+ what: string,
119
+ verb = 'vx upgrade',
120
+ ): Promise<Response> {
116
121
  try {
117
122
  return await fetch(url, init)
118
123
  } catch (err) {
119
124
  const message = err instanceof Error ? err.message : String(err)
120
125
  throw new UserError(
121
- `vx upgrade: could not reach ${new URL(url).host} to ${what} (${message}) — check the network or the proxy and re-run`,
126
+ `${verb}: could not reach ${new URL(url).host} to ${what} (${message}) — check the network or the proxy and re-run`,
122
127
  )
123
128
  }
124
129
  }
@@ -130,33 +135,43 @@ async function fetchOrRefuse(url: string, init: RequestInit, what: string): Prom
130
135
  * connection was closed unexpectedly"), which `fetchOrRefuse` never sees
131
136
  * and the CLI printed as an internal error with a stack.
132
137
  */
133
- async function readOrRefuse<T>(read: () => Promise<T>, url: string, what: string): Promise<T> {
138
+ async function readOrRefuse<T>(
139
+ read: () => Promise<T>,
140
+ url: string,
141
+ what: string,
142
+ verb = 'vx upgrade',
143
+ ): Promise<T> {
134
144
  try {
135
145
  return await read()
136
146
  } catch (err) {
137
147
  const message = err instanceof Error ? err.message : String(err)
138
148
  throw new UserError(
139
- `vx upgrade: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
149
+ `${verb}: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
140
150
  )
141
151
  }
142
152
  }
143
153
 
144
154
  /** The registry's document for `pkg@spec` (`latest` or a version). */
145
- export async function fetchManifest(pkg: string, spec: string): Promise<unknown> {
155
+ export async function fetchManifest(
156
+ pkg: string,
157
+ spec: string,
158
+ verb = 'vx upgrade',
159
+ ): Promise<unknown> {
146
160
  const url = `${REGISTRY}/${pkg}/${spec}`
147
161
  const res = await fetchOrRefuse(
148
162
  url,
149
163
  { redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': `vx/${VERSION}` } },
150
164
  'read the release',
165
+ verb,
151
166
  )
152
167
  if (!res.ok) {
153
168
  throw new UserError(
154
- `vx upgrade: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
169
+ `${verb}: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
155
170
  )
156
171
  }
157
172
  // A body that is not JSON (a captive portal's page, a proxy's error
158
173
  // page served with a 200) is the same refusal as a cut one.
159
- return await readOrRefuse(() => res.json(), url, 'read the release')
174
+ return await readOrRefuse(() => res.json(), url, 'read the release', verb)
160
175
  }
161
176
 
162
177
  /** What a verified registry document names: the version, its tarball and the signed integrity. */
@@ -395,6 +410,8 @@ export interface ManagedInstall {
395
410
  cwd: string
396
411
  /** The project field that lists `@vzn/vx`. */
397
412
  field?: 'dependencies' | 'devDependencies' | 'optionalDependencies'
413
+ /** The `@vzn/vx-*` plugins the same package.json lists from the registry, moved with core. */
414
+ plugins?: string[]
398
415
  }
399
416
 
400
417
  const FIELDS = ['devDependencies', 'dependencies', 'optionalDependencies'] as const
@@ -417,6 +434,45 @@ function listingField(dir: string): ManagedInstall['field'] | undefined {
417
434
  })
418
435
  }
419
436
 
437
+ /** A release's platform binary, which the launcher pulls in itself: no plugin. */
438
+ const PLATFORM = /^@vzn\/vx-(?:darwin|linux)-(?:x64|arm64)$/
439
+
440
+ /**
441
+ * The `@vzn/vx-*` plugins `dir`'s package.json lists from the registry
442
+ * (a `workspace:` or `file:` link is the repo's own). Every @vzn package
443
+ * releases together, so they move with core in its one install (M,
444
+ * 2026-10-16); one in another field would move fields under core's flag,
445
+ * so it is refused.
446
+ */
447
+ function pluginsBeside(dir: string, field: NonNullable<ManagedInstall['field']>): string[] {
448
+ const pkg = readJson(path.join(dir, 'package.json'))
449
+ const plugins: string[] = []
450
+ for (const f of FIELDS) {
451
+ const deps = pkg?.[f]
452
+ if (typeof deps !== 'object' || deps === null) continue
453
+ for (const [name, spec] of Object.entries(deps as Record<string, unknown>)) {
454
+ if (!name.startsWith(`${PACKAGE}-`) || PLATFORM.test(name)) continue
455
+ if (typeof spec !== 'string' || /^(?:workspace|file|link|portal):/.test(spec)) continue
456
+ if (f !== field) {
457
+ throw new UserError(
458
+ `vx upgrade: ${name} is in ${f} and ${PACKAGE} in ${field}; list them in one field so one install moves them together`,
459
+ )
460
+ }
461
+ plugins.push(name)
462
+ }
463
+ }
464
+ return plugins.sort()
465
+ }
466
+
467
+ /** The version of `pkg` installed where `dir` resolves it, if any. */
468
+ function installedVersion(dir: string, pkg: string): string | undefined {
469
+ for (let d = dir; ; d = path.dirname(d)) {
470
+ const v = readJson(path.join(d, 'node_modules', pkg, 'package.json'))?.['version']
471
+ if (typeof v === 'string') return v
472
+ if (path.dirname(d) === d) return undefined
473
+ }
474
+ }
475
+
420
476
  /** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
421
477
  function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
422
478
  for (let d = dir; ; d = path.dirname(d)) {
@@ -463,12 +519,15 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
463
519
  if (/\/yarn\/global\//.test(p)) return { manager: 'yarn', scope: 'global', cwd: root }
464
520
  const field = listingField(root)
465
521
  if (field !== undefined) {
466
- return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field }
522
+ const plugins = pluginsBeside(root, field)
523
+ return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field, plugins }
467
524
  }
468
525
  for (let d = path.resolve(cwd); d.startsWith(root); d = path.dirname(d)) {
469
526
  const f = listingField(d)
470
- if (f !== undefined)
471
- return { manager: projectManager(d, execPath), scope: 'project', cwd: d, field: f }
527
+ if (f !== undefined) {
528
+ const plugins = pluginsBeside(d, f)
529
+ return { manager: projectManager(d, execPath), scope: 'project', cwd: d, field: f, plugins }
530
+ }
472
531
  if (path.dirname(d) === d) break
473
532
  }
474
533
  // npm's global tree: `<prefix>/lib/node_modules` (nvm, volta, Homebrew alike).
@@ -503,7 +562,8 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
503
562
  bun: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
504
563
  yarn: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
505
564
  }[manager][field]
506
- const argv = manager === 'npm' ? ['npm', 'install', spec] : [manager, 'add', spec]
565
+ const specs = [spec, ...(install.plugins ?? []).map((p) => `${p}@${version}`)]
566
+ const argv = manager === 'npm' ? ['npm', 'install', ...specs] : [manager, 'add', ...specs]
507
567
  if (flag !== '') argv.push(flag)
508
568
  // pnpm refuses an `add` at a workspace's root without saying it means the root.
509
569
  if (manager === 'pnpm' && existsSync(path.join(install.cwd, 'pnpm-workspace.yaml')))
@@ -511,13 +571,17 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
511
571
  return argv
512
572
  }
513
573
 
514
- /** The version `tag` names, or the registry's latest, read for the "already at" check. */
515
- async function targetVersion(tag: string | undefined): Promise<string> {
574
+ /**
575
+ * The version `tag` names, or the registry's latest: read for the "already
576
+ * at" check, and by `vx init`, which always installs the latest release.
577
+ */
578
+ export async function targetVersion(tag: string | undefined, verb = 'vx upgrade'): Promise<string> {
516
579
  const spec = versionSpec(tag)
517
580
  if (spec !== 'latest') return spec
518
- const v = ((await fetchManifest(PACKAGE, 'latest')) as { version?: unknown } | null)?.version
581
+ const v = ((await fetchManifest(PACKAGE, 'latest', verb)) as { version?: unknown } | null)
582
+ ?.version
519
583
  if (typeof v !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
520
- throw new UserError(`vx upgrade: the npm registry named no latest version of ${PACKAGE}`)
584
+ throw new UserError(`${verb}: the npm registry named no latest version of ${PACKAGE}`)
521
585
  }
522
586
  return v
523
587
  }
@@ -527,13 +591,14 @@ async function targetVersion(tag: string | undefined): Promise<string> {
527
591
  * `version`; vx never installs one. `step` names the install when a run
528
592
  * moves two (`project `, `global `).
529
593
  */
530
- async function upgradeManaged(
594
+ export async function upgradeManaged(
531
595
  install: ManagedInstall,
532
596
  version: string,
533
597
  current = VERSION,
534
598
  step = '',
535
599
  ): Promise<void> {
536
- if (version === current) {
600
+ const plugins = install.plugins ?? []
601
+ if (version === current && plugins.every((p) => installedVersion(install.cwd, p) === version)) {
537
602
  ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
538
603
  return
539
604
  }
@@ -547,7 +612,11 @@ async function upgradeManaged(
547
612
  }
548
613
  ui.print(
549
614
  'out',
550
- ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
615
+ ui.status(
616
+ 'info',
617
+ 'vx upgrade',
618
+ `${step}${version === current ? 'plugins' : current} → ${version}`,
619
+ ),
551
620
  ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
552
621
  )
553
622
  const proc = Bun.spawnSync({
@@ -557,7 +626,14 @@ async function upgradeManaged(
557
626
  })
558
627
  if (proc.exitCode !== 0) {
559
628
  throw new UserError(
560
- `vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself`,
629
+ `vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself${
630
+ // The install is pinned, so pnpm refuses a release younger than
631
+ // its minimumReleaseAge instead of mixing releases; its words
632
+ // print above, so the way out is named (as vx-migrate does).
633
+ install.manager === 'pnpm'
634
+ ? `; if pnpm refused a version published within its minimumReleaseAge, list ${[PACKAGE, ...plugins].join(', ')} under minimumReleaseAgeExclude in pnpm-workspace.yaml`
635
+ : ''
636
+ }`,
561
637
  )
562
638
  }
563
639
  }
@@ -2,6 +2,7 @@
2
2
  // rules, git-ignored paths, the root arm's filter) and which files reshape
3
3
  // the watched set. Pure decisions over paths; the loop acts on them.
4
4
 
5
+ import fs from 'node:fs'
5
6
  import path from 'node:path'
6
7
  import {
7
8
  executablePath,
@@ -369,12 +370,14 @@ export function gitSpeller(root: string): (p: string) => string {
369
370
  * a project moved away whole is one event on its directory): what existed
370
371
  * when watch armed, so a path born and gone since is told from a deletion
371
372
  * (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
372
- * so a server cannot be blamed for one. Undefined when git cannot answer:
373
+ * so a server cannot be blamed for one. `links` is the symlinks among the
374
+ * files: a key reads what each points to (cache/linked-inputs.ts), so watch
375
+ * arms that too. Undefined when git cannot answer:
373
376
  * no inventory, every gone path is a deletion as before.
374
377
  */
375
378
  export function gitFiles(
376
379
  workspaceRoot: string,
377
- ): { listed: Set<string>; tracked: Set<string> } | undefined {
380
+ ): { listed: Set<string>; tracked: Set<string>; links: Set<string> } | undefined {
378
381
  let proc: ReturnType<typeof Bun.spawnSync>
379
382
  try {
380
383
  proc = Bun.spawnSync({
@@ -383,6 +386,7 @@ export function gitFiles(
383
386
  'ls-files',
384
387
  '-z',
385
388
  '-t',
389
+ '-s',
386
390
  '--cached',
387
391
  '--others',
388
392
  '--exclude-standard',
@@ -398,18 +402,30 @@ export function gitFiles(
398
402
  if (proc.exitCode !== 0) return undefined
399
403
  const files = new Set<string>()
400
404
  const tracked = new Set<string>()
405
+ const links = new Set<string>()
401
406
  for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
402
407
  .decode(proc.stdout)
403
408
  .split('\0')) {
404
409
  if (entry.length === 0) continue
405
- // `-t` prefixes a tag and a space; `?` is untracked.
406
- const p = entry.slice(2)
410
+ // `-t` prefixes a tag and a space; `?` is untracked, a bare path. A
411
+ // tracked entry is `-s`'s `<mode> <oid> <stage>\t<path>` after the tag.
412
+ const untracked = entry[0] === '?'
413
+ const p = untracked ? entry.slice(2) : entry.slice(entry.indexOf('\t') + 1)
407
414
  // An untracked nested repository is listed as `dir/`.
408
415
  let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
409
416
  files.add(abs)
410
- if (entry[0] !== '?') tracked.add(abs)
417
+ if (!untracked) tracked.add(abs)
418
+ if (untracked ? isLink(abs) : entry.startsWith('120000 ', 2)) links.add(abs)
411
419
  for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
412
420
  files.add(abs)
413
421
  }
414
- return { listed: files, tracked }
422
+ return { listed: files, tracked, links }
423
+ }
424
+
425
+ function isLink(abs: string): boolean {
426
+ try {
427
+ return fs.lstatSync(abs).isSymbolicLink()
428
+ } catch {
429
+ return false
430
+ }
415
431
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
+ import { filesBehind } from '../cache/index.js'
7
8
  import { xxh3 } from '../util/index.js'
8
9
  import { gitIgnored, gitSpeller } from './watch-filter.js'
9
10
  import { modifiedBefore } from './watch-fs.js'
@@ -13,9 +14,10 @@ const ABSENT = -1n
13
14
  function settledState(abs: string): bigint {
14
15
  let st: fs.Stats
15
16
  try {
16
- // A link is its target string, as the key folds it: following it saw
17
- // a retarget to equal bytes as "the same" and ran nothing.
18
- if (fs.lstatSync(abs).isSymbolicLink()) return xxh3(`link:${fs.readlinkSync(abs)}`)
17
+ // A link is its target string and what it points to, as the key folds
18
+ // it: following it alone saw a retarget to equal bytes as "the same".
19
+ if (fs.lstatSync(abs).isSymbolicLink())
20
+ return xxh3(`link:${fs.readlinkSync(abs)}\0${behindState(abs)}`)
19
21
  st = fs.statSync(abs)
20
22
  } catch {
21
23
  return ABSENT
@@ -50,6 +52,34 @@ function settledState(abs: string): bigint {
50
52
  return xxh3(Buffer.from(entries.join('\n')))
51
53
  }
52
54
 
55
+ /**
56
+ * What a link points to, as change detection reads it: a file's bytes and
57
+ * mode, a directory's files by size and times (cache/linked-inputs.ts keys
58
+ * their bytes), nothing for a dangling link.
59
+ */
60
+ function behindState(link: string): string {
61
+ let real: string
62
+ let st: fs.Stats
63
+ try {
64
+ real = fs.realpathSync(link)
65
+ st = fs.statSync(real)
66
+ } catch {
67
+ return ''
68
+ }
69
+ if (st.isFile()) return `${xxh3(fs.readFileSync(real))}:${st.mode & 0o7777}`
70
+ if (!st.isDirectory()) return ''
71
+ return filesBehind(real)
72
+ .map((rel) => {
73
+ try {
74
+ const f = fs.statSync(path.join(real, rel))
75
+ return `${rel}\0${f.size}\0${f.mtimeMs}\0${f.ctimeMs}`
76
+ } catch {
77
+ return rel
78
+ }
79
+ })
80
+ .join('\n')
81
+ }
82
+
53
83
  /** What the judgement reads from the loop, which owns it and may replace it. */
54
84
  export interface JudgeContext {
55
85
  workspaceRoot: string
@@ -59,6 +89,8 @@ export interface JudgeContext {
59
89
  held(): boolean
60
90
  /** Projects whose uncached task may read a git-ignored file. */
61
91
  uncached(): ReadonlySet<string>
92
+ /** Paths a task's inputs name outright: a git-ignored file at or under one is an input. */
93
+ namedIgnored?(): readonly string[]
62
94
  /** Whether `abs`, under the project at `ownDir`, lies in a project nested there (`makeFence`). */
63
95
  fenced?(ownDir: string, abs: string): boolean
64
96
  /** The files git listed at the arm; absent when git could not answer. */
@@ -113,6 +145,15 @@ export class ChangeJudge {
113
145
 
114
146
  constructor(private readonly ctx: JudgeContext) {}
115
147
 
148
+ /**
149
+ * Record a path's state as it is, if this loop has not: a link armed for
150
+ * what it points to, whose own times say "before the arm" whatever
151
+ * changed behind it, so its first sighting compares against this.
152
+ */
153
+ seed(abs: string): void {
154
+ if (!this.lastState.has(abs)) this.lastState.set(abs, settledState(abs))
155
+ }
156
+
116
157
  // A path this loop has never judged is a change only if it moved since
117
158
  // the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
118
159
  // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
@@ -192,10 +233,13 @@ export class ChangeJudge {
192
233
  // user wrote it; one the last cycle wrote (the pid file an uncached
193
234
  // task rewrites every run, the loop the filter exists for) stays out,
194
235
  // as does one in a project nested under it, which it does not own.
236
+ // So is one a task's inputs name outright (`.env.local`, `dist/**`).
195
237
  const editForUncached = (p: string): boolean =>
196
- [...this.ctx.uncached()].some(
238
+ ([...this.ctx.uncached()].some(
197
239
  (dir) => p.startsWith(dir + path.sep) && this.ctx.fenced?.(dir, p) !== true,
198
- ) && !this.writtenDuringLastCycle(p)
240
+ ) ||
241
+ (this.ctx.namedIgnored?.() ?? []).some((n) => p === n || p.startsWith(n + path.sep))) &&
242
+ !this.writtenDuringLastCycle(p)
199
243
  // A path still there names the cycle before a gone one: an editor or
200
244
  // `sed -i` saving through a temporary file fires that file first, and
201
245
  // the cycle was announced by a name already renamed away.