@vzn/vx 0.0.90 → 0.0.91

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.90",
4
- "description": "An open, extensible monorepo task runner.",
3
+ "version": "0.0.91",
4
+ "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.90",
21
- "@vzn/vx-linux-arm64": "0.0.90",
22
- "@vzn/vx-darwin-x64": "0.0.90",
23
- "@vzn/vx-darwin-arm64": "0.0.90"
20
+ "@vzn/vx-linux-x64": "0.0.91",
21
+ "@vzn/vx-linux-arm64": "0.0.91",
22
+ "@vzn/vx-darwin-x64": "0.0.91",
23
+ "@vzn/vx-darwin-arm64": "0.0.91"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -36,16 +36,20 @@
36
36
  "type": "git",
37
37
  "url": "git+https://github.com/vznjs/vx.git"
38
38
  },
39
- "homepage": "https://github.com/vznjs/vx#readme",
39
+ "homepage": "https://vznjs.github.io/vx/",
40
40
  "bugs": "https://github.com/vznjs/vx/issues",
41
41
  "license": "MIT",
42
42
  "keywords": [
43
+ "vx",
43
44
  "monorepo",
44
45
  "task-runner",
45
- "build",
46
+ "build-cache",
47
+ "build-system",
46
48
  "cache",
47
- "bun",
48
49
  "turborepo",
49
- "nx"
50
+ "nx",
51
+ "bun",
52
+ "workspaces",
53
+ "ci"
50
54
  ]
51
55
  }
@@ -40,6 +40,7 @@ import {
40
40
  span,
41
41
  splitTaskId,
42
42
  taskGlob,
43
+ xxh3hex,
43
44
  } from '../util/index.js'
44
45
  import {
45
46
  ArchiveSecurityError,
@@ -655,7 +656,7 @@ export class Cache implements CacheLayer {
655
656
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
656
657
  this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
657
658
  this.outputs = new OutputIndex(this.db)
658
- this.history = new RunHistory(this.db)
659
+ this.history = new RunHistory(this.db, (v) => this.digestValue(v))
659
660
 
660
661
  // A CACHE_VERSION bump keeps the index but moves every key, so the run
661
662
  // after an upgrade misses everything. Roadmap 3.3: that is announced,
@@ -753,9 +754,47 @@ export class Cache implements CacheLayer {
753
754
  input,
754
755
  (f) => this.hashFile(f),
755
756
  (f) => this.relFor(input.workspaceRoot, f),
757
+ (v) => this.digestValue(v),
756
758
  )
757
759
  }
758
760
 
761
+ private valueSalt: string | undefined
762
+
763
+ /**
764
+ * The digest a value-bearing row holds (an env value, a runtime output,
765
+ * args after `--`): xxh3 under a random salt this store keeps. `vx why`,
766
+ * its JSON and MCP's `whyDidThisRerun` print it, and an unkeyed xxh3 in
767
+ * a public CI log let anyone confirm or brute-force a short secret
768
+ * (L-4). Salted, a digest still says whether a value changed, and says
769
+ * nothing without this cache's `schema_meta`. Asked for on a miss only.
770
+ */
771
+ private digestValue(value: string): string {
772
+ this.valueSalt ??= this.loadValueSalt()
773
+ return xxh3hex(`${this.valueSalt}\0${value}`)
774
+ }
775
+
776
+ private loadValueSalt(): string {
777
+ const read = (): string | undefined =>
778
+ (
779
+ this.db.prepare("SELECT value FROM schema_meta WHERE key = 'value_salt'").get() as {
780
+ value: string
781
+ } | null
782
+ )?.value
783
+ const found = read()
784
+ if (found !== undefined) return found
785
+ const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex')
786
+ // A store this process may not write keeps the salt for the process:
787
+ // nothing it digests is persisted.
788
+ if (this.writeBlocked !== null || this.inspecting) return salt
789
+ // Two processes opening one new store: the first salt stands.
790
+ this.db
791
+ .prepare(
792
+ "INSERT INTO schema_meta(key, value) VALUES ('value_salt', ?) ON CONFLICT(key) DO NOTHING",
793
+ )
794
+ .run(salt)
795
+ return read() ?? salt
796
+ }
797
+
759
798
  // ctx is accepted but ignored — the local layer reads metadata from
760
799
  // the entries row. It's part of the contract so LayeredCache can
761
800
  // route metadata to `ingest()` on remote-hit without a separate API.
@@ -99,6 +99,12 @@ export async function foldKey(
99
99
  input: CacheKeyInput,
100
100
  hashFile: (file: string) => Promise<string>,
101
101
  relOf: (file: string) => string,
102
+ /**
103
+ * The digest a value-bearing capture row holds. `Cache` keys it with a
104
+ * salt of its own (L-4); the default is for a caller that persists and
105
+ * prints nothing.
106
+ */
107
+ digestValue: (value: string) => string = xxh3hex,
102
108
  ): Promise<string> {
103
109
  // Seed-chained xxHash3: each step folds one field into the
104
110
  // running digest via `xxh3(part, prevDigest)`. Equivalent to the
@@ -124,16 +130,18 @@ export async function foldKey(
124
130
  // DIGEST of each component, never the raw value. For value-bearing kinds
125
131
  // (env, runtime, ws-runtime, forward) the payload is a secret or
126
132
  // sensitive string (API keys via cache.inputs.env, runtime-command
127
- // output, args after `--`), so we push `xxh3hex(v)` — the diff consumer
128
- // only needs to know whether a component CHANGED, which a digest
129
- // preserves losslessly. cache.db must never hold plaintext secrets at
130
- // rest. The cache KEY (`h`) folds the plaintext separately below and is
133
+ // output, args after `--`), so we push `digestValue(v)` — the diff
134
+ // consumer only needs to know whether a component CHANGED, which a
135
+ // digest preserves losslessly. cache.db must never hold plaintext
136
+ // secrets at rest, and `vx why` prints the digest, so it is salted: an
137
+ // unkeyed 64-bit xxh3 in a public CI log gave a short secret away to a
138
+ // brute force (L-4). The cache KEY (`h`) folds the plaintext separately below and is
131
139
  // unaffected.
132
140
  const forwarded = input.forwardArgs ?? []
133
141
  h = xxh3(`forward-args:${forwarded.length}`, h)
134
142
  for (const a of forwarded) h = xxh3(a, h)
135
143
  if (cap && forwarded.length > 0) {
136
- cap.push({ kind: 'forward', name: 'argv', hash: xxh3hex(JSON.stringify(forwarded)) })
144
+ cap.push({ kind: 'forward', name: 'argv', hash: digestValue(JSON.stringify(forwarded)) })
137
145
  }
138
146
 
139
147
  h = xxh3(`env-values:${input.envValues.length}`, h)
@@ -145,20 +153,20 @@ export async function foldKey(
145
153
  for (const [n, v] of input.envValues) h = xxh3(v === undefined ? n : `${n}\0${v}`, h)
146
154
  if (cap)
147
155
  for (const [n, v] of input.envValues)
148
- cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' : xxh3hex(v) })
156
+ cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' : digestValue(v) })
149
157
 
150
158
  const runtimeValues = input.runtimeValues ?? []
151
159
  h = xxh3(`runtime-values:${runtimeValues.length}`, h)
152
160
  for (const [c, o] of runtimeValues) h = xxh3(`${c}\0${o}`, h)
153
161
  if (cap)
154
- for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: xxh3hex(o) })
162
+ for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: digestValue(o) })
155
163
 
156
164
  const wsRuntimeValues = input.workspaceRuntimeValues ?? []
157
165
  h = xxh3(`ws-runtime-values:${wsRuntimeValues.length}`, h)
158
166
  for (const [c, o] of wsRuntimeValues) h = xxh3(`${c}\0${o}`, h)
159
167
  if (cap)
160
168
  for (const [c, o] of wsRuntimeValues)
161
- cap.push({ kind: 'ws-runtime', name: c, hash: xxh3hex(o) })
169
+ cap.push({ kind: 'ws-runtime', name: c, hash: digestValue(o) })
162
170
 
163
171
  const upstream = [...input.upstreamHashes].sort()
164
172
  h = xxh3(`upstream:${upstream.length}`, h)
@@ -174,7 +182,7 @@ export async function foldKey(
174
182
  h = xxh3(`plugin:${pluginParts.length}`, h)
175
183
  for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
176
184
  if (cap) {
177
- for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: xxh3hex(v) })
185
+ for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: digestValue(v) })
178
186
  }
179
187
  }
180
188
 
@@ -4,14 +4,17 @@
4
4
  // its statements over the store's handle; `Cache` delegates.
5
5
 
6
6
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
- import { xxh3hex } from '../util/index.js'
8
7
  import type { InvocationRecord, RunRecord } from './layer.js'
9
8
 
10
9
  export class RunHistory {
11
10
  private readonly insertRun: ReturnType<Database['prepare']>
12
11
  private readonly insertInvocation: ReturnType<Database['prepare']>
13
12
 
14
- constructor(private readonly db: Database) {
13
+ constructor(
14
+ private readonly db: Database,
15
+ /** The store's salted value digest (L-4). */
16
+ private readonly digestValue: (value: string) => string,
17
+ ) {
15
18
  this.insertRun = this.db.prepare(`
16
19
  INSERT INTO runs(
17
20
  hash, project, task, status, exit_code, duration_ms, forward_args,
@@ -37,13 +40,13 @@ export class RunHistory {
37
40
  }
38
41
 
39
42
  recordRun(run: RunRecord): void {
40
- this.insertRun.run(...bindRun(run))
43
+ this.insertRun.run(...bindRun(run, this.digestValue))
41
44
  }
42
45
 
43
46
  recordRuns(runs: readonly RunRecord[]): void {
44
47
  if (runs.length === 0) return
45
48
  if (runs.length === 1) {
46
- this.insertRun.run(...bindRun(runs[0]!))
49
+ this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
47
50
  return
48
51
  }
49
52
  // `bun:sqlite`'s `transaction()` returns a callable that wraps the
@@ -51,7 +54,7 @@ export class RunHistory {
51
54
  // run that's one fsync instead of 200.
52
55
  const insert = this.insertRun
53
56
  const tx = this.db.transaction((batch: readonly RunRecord[]) => {
54
- for (const r of batch) insert.run(...bindRun(r))
57
+ for (const r of batch) insert.run(...bindRun(r, this.digestValue))
55
58
  })
56
59
  tx(runs)
57
60
  }
@@ -66,7 +69,7 @@ export class RunHistory {
66
69
  const insertRun = this.insertRun
67
70
  const insertInvocation = this.insertInvocation
68
71
  this.db.transaction(() => {
69
- for (const r of bundle.runs) insertRun.run(...bindRun(r))
72
+ for (const r of bundle.runs) insertRun.run(...bindRun(r, this.digestValue))
70
73
  insertInvocation.run(...bindInvocation(bundle.invocation))
71
74
  })()
72
75
  }
@@ -89,7 +92,7 @@ export class RunHistory {
89
92
  * `insertRun` prepared statement (21 columns). Shared between the
90
93
  * single and batched record paths.
91
94
  */
92
- function bindRun(run: RunRecord): SQLQueryBindings[] {
95
+ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
93
96
  return [
94
97
  // The ONE place the no-key sentinel is applied, so the column's
95
98
  // NOT NULL invariant can't be violated from a call site.
@@ -102,7 +105,7 @@ function bindRun(run: RunRecord): SQLQueryBindings[] {
102
105
  // A digest, as entry_inputs keeps: args after `--` carry tokens
103
106
  // (`--token=…`), and cache.db holds no plaintext secret at rest; nothing
104
107
  // reads the column but for whether the args changed (item 1091).
105
- run.forwardArgs ? xxh3hex(JSON.stringify(run.forwardArgs)) : null,
108
+ run.forwardArgs ? digestValue(JSON.stringify(run.forwardArgs)) : null,
106
109
  run.startedAt,
107
110
  run.endedAt,
108
111
  run.runId ?? null,
package/src/cli/cache.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { Cache, noteSchemaReset } from '../cache/index.js'
3
- import { seeHelp } from './help.js'
3
+ import { flagHint, seeHelp } from './help.js'
4
4
  import { nearest, parseDuration, parseSize } from '../util/index.js'
5
5
  import { acquireRunLock } from '../orchestrator/index.js'
6
6
  import { findWorkspaceRoot } from '../workspace/index.js'
@@ -112,7 +112,10 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
112
112
  out.dryRun = true
113
113
  } else {
114
114
  const cd = parseCacheDirFlag(args, i)
115
- if (cd === null) return { error: `unknown argument: ${a}${seeHelp('cache')}` }
115
+ if (cd === null)
116
+ return {
117
+ error: `unknown argument: ${a}${flagHint('cache prune', a ?? '')}${seeHelp('cache')}`,
118
+ }
116
119
  if ('error' in cd) return { error: cd.error }
117
120
  out.cacheDir = cd.cacheDir
118
121
  i = cd.next
package/src/cli/help.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { nearest } from '../util/index.js'
2
+
1
3
  export function printHelp(pluginCommands: readonly string[] = [], verb?: string): void {
2
4
  process.stdout.write(verb === undefined ? helpText(pluginCommands) : verbHelpText(verb))
3
5
  }
@@ -188,6 +190,26 @@ export function seeHelp(verb: string): string {
188
190
  return ` (see \`vx ${verb} --help\`)`
189
191
  }
190
192
 
193
+ /**
194
+ * `(did you mean --concurrency?)` for a flag within two edits of one `verb`
195
+ * accepts; empty for anything else (a positional included). A third edit
196
+ * is allowed only between flags that share their first five characters,
197
+ * so `--retries` reaches `--retry` while `--zzz` does not reach `--all`.
198
+ */
199
+ export function flagHint(verb: string, arg: string): string {
200
+ if (!arg.startsWith('-')) return ''
201
+ const name = arg.replace(/=.*$/, '')
202
+ const flags = acceptedFlags(verb)
203
+ const best =
204
+ nearest(name, flags) ??
205
+ nearest(
206
+ name,
207
+ flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
208
+ 3,
209
+ )
210
+ return best === undefined ? '' : ` (did you mean ${best}?)`
211
+ }
212
+
191
213
  /**
192
214
  * The documented `--flags` of one verb, read from the help text's
193
215
  * sections headed `… (for <verb>):` so there is no second list to drift —
package/src/cli/info.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // too; this file parses the flags and renders the rows.
6
6
 
7
7
  import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
8
- import { seeHelp } from './help.js'
8
+ import { flagHint, seeHelp } from './help.js'
9
9
  import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
10
  import { formatBytes } from './format.js'
11
11
  import { MIN_BUN } from '../util/index.js'
@@ -36,7 +36,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
36
36
  i = cd.next
37
37
  continue
38
38
  }
39
- return { ...out, error: `unknown argument: ${a}${seeHelp('info')}` }
39
+ return { ...out, error: `unknown argument: ${a}${flagHint('info', a ?? '')}${seeHelp('info')}` }
40
40
  }
41
41
  return out
42
42
  }
package/src/cli/init.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // the scripts is the richer source, and `@vzn/vx-migrate` maps it.
5
5
 
6
6
  import path from 'node:path'
7
- import { seeHelp } from './help.js'
7
+ import { flagHint, seeHelp } from './help.js'
8
8
  import {
9
9
  applyMigration,
10
10
  findWorkspaceRoot,
@@ -28,7 +28,8 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
28
28
  if (a === '--dry') out.dry = true
29
29
  else if (a === '--force') out.force = true
30
30
  else if (a === '--mjs') out.mjs = true
31
- else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('init')}` }
31
+ else if (a.startsWith('-'))
32
+ return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
32
33
  else return { ...out, error: `unexpected argument: ${a}` }
33
34
  }
34
35
  return out
package/src/cli/last.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { Cache, noteSchemaReset } from '../cache/index.js'
8
8
  import { formatBytes } from './format.js'
9
- import { seeHelp } from './help.js'
9
+ import { flagHint, seeHelp } from './help.js'
10
10
  import {
11
11
  exitSignal,
12
12
  getInvocation,
@@ -62,7 +62,8 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
62
62
  i = cd.next
63
63
  continue
64
64
  }
65
- if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('last')}` }
65
+ if (a.startsWith('-'))
66
+ return { ...out, error: `unknown flag: ${a}${flagHint('last', a)}${seeHelp('last')}` }
66
67
  if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
67
68
  out.runId = a
68
69
  }
package/src/cli/lock.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // current environment. Design: docs/design/config-lock-2026-06.md.
4
4
 
5
5
  import type { ProjectConfig } from '../config.js'
6
- import { seeHelp } from './help.js'
6
+ import { flagHint, seeHelp } from './help.js'
7
7
  import { relPosix, xxh3hex } from '../util/index.js'
8
8
  import {
9
9
  findWorkspaceRoot,
@@ -29,7 +29,11 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
29
29
  const out: LockArgs = { check: false }
30
30
  for (const a of args) {
31
31
  if (a === '--check') out.check = true
32
- else return { check: false, error: `unknown argument: ${a}${seeHelp('lock')}` }
32
+ else
33
+ return {
34
+ check: false,
35
+ error: `unknown argument: ${a}${flagHint('lock', a)}${seeHelp('lock')}`,
36
+ }
33
37
  }
34
38
  return out
35
39
  }
package/src/cli/run.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { documentedFlags, seeHelp } from './help.js'
3
+ import { flagHint, seeHelp } from './help.js'
4
4
  import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
5
5
  import {
6
6
  planRun,
@@ -15,13 +15,7 @@ import {
15
15
  import type { ContinueMode } from '../graph/index.js'
16
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
17
17
  import { findCwdProject, pickTask, resolveFilters } from './select.js'
18
- import {
19
- MAX_TIMEOUT_MS,
20
- isUserError,
21
- parseDecimalInt,
22
- nearest,
23
- machineParallelism,
24
- } from '../util/index.js'
18
+ import { MAX_TIMEOUT_MS, isUserError, parseDecimalInt, machineParallelism } from '../util/index.js'
25
19
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
26
20
 
27
21
  export interface RunArgs {
@@ -343,7 +337,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
343
337
  }
344
338
  out.report = fmt
345
339
  } else if (a !== undefined && a.startsWith('-')) {
346
- return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}${seeHelp('run')}` }
340
+ return { ...out, error: `unknown flag: ${a}${flagHint('run', a)}${seeHelp('run')}` }
347
341
  } else if (a !== undefined) {
348
342
  out.tasks.push(a)
349
343
  }
@@ -695,20 +689,3 @@ function printSummary(summary: RunResult): void {
695
689
  function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
696
690
  return { task: o.taskId, status: outcomeLabel(o), duration: `${o.durationMs}ms` }
697
691
  }
698
-
699
- /** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
700
- function didYouMeanFlag(flag: string): string {
701
- const name = flag.replace(/=.*$/, '')
702
- const flags = documentedFlags('run')
703
- // The usual two edits; a third only between flags that share their
704
- // first five characters, so `--retries` reaches `--retry` (the i/y)
705
- // while `--zzz` does not reach `--all` — a plain three-edit budget did.
706
- const best =
707
- nearest(name, flags) ??
708
- nearest(
709
- name,
710
- flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
711
- 3,
712
- )
713
- return best === undefined ? '' : ` (did you mean ${best}?)`
714
- }
package/src/cli/show.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { ProjectConfig, TaskConfig } from '../config.js'
10
10
  import { declaredTask } from '../graph/index.js'
11
- import { seeHelp } from './help.js'
11
+ import { flagHint, seeHelp } from './help.js'
12
12
  import { nearMatches, relPosix, UserError } from '../util/index.js'
13
13
  import { loadCliProjects } from './workspace-config.js'
14
14
  import {
@@ -33,7 +33,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
33
33
  let format: string | undefined
34
34
  if (a === '--format') format = args[++i] ?? ''
35
35
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
36
- else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('show')}` }
36
+ else if (a.startsWith('-'))
37
+ return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
37
38
  else if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
38
39
  else out.target = a
39
40
 
package/src/cli/why.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // no config evaluation, no re-hash.
7
7
 
8
8
  import { Cache, noteSchemaReset } from '../cache/index.js'
9
- import { seeHelp } from './help.js'
9
+ import { flagHint, seeHelp } from './help.js'
10
10
  import { splitTaskId } from '../graph/index.js'
11
11
  import {
12
12
  cacheKeyDiff,
@@ -83,7 +83,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
83
83
  i = cd.next
84
84
  continue
85
85
  }
86
- if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('why')}` }
86
+ if (a.startsWith('-'))
87
+ return { ...out, error: `unknown flag: ${a}${flagHint('why', a)}${seeHelp('why')}` }
87
88
  if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
88
89
  out.target = a
89
90
  }
@@ -340,6 +340,19 @@ export function dependsOnSiblingOutputs(
340
340
  const outputs = sameProject.outputsOf.get(node.projectName) ?? []
341
341
  if (inputs === undefined || outputs.length === 0) return true
342
342
  if (workspaceInputsReach(inputs, outputs.map(literalPrefix))) return true
343
+ // Nor where it WRITES inside another task's tree: restored ahead of
344
+ // that producer, its restore raced the producer's clean and extract
345
+ // (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
346
+ // `outputsOf` holds this task's own globs too; set them aside once.
347
+ const own = cache.outputs.files
348
+ if (own.length > 0) {
349
+ const others = [...outputs]
350
+ for (const g of own) {
351
+ const i = others.indexOf(g)
352
+ if (i !== -1) others.splice(i, 1)
353
+ }
354
+ if (others.length > 0 && workspaceInputsReach(own, others.map(literalPrefix))) return true
355
+ }
343
356
  }
344
357
  // A root-anchored output is boundary-IGNORING by design, so it can land
345
358
  // inside THIS task's own project dir — where an ordinary project-relative