@stravica-ai/digital-operator-pa 0.1.64 → 0.1.65

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.
@@ -115,6 +115,19 @@ pre-remote library sweep.
115
115
  Runs the same in SIMPLE and REMOTE mode (GitHub-side maintenance verb,
116
116
  no ingest touch). Config in the `library.upMerge` stanza; see below.
117
117
 
118
+ - `library migrate-mounts --dry-run|--check [--tier <name>]... [--clone <path>]... [--ref <ref>] [--no-fetch] [--out <dir>] [--baseline <csv>] [--repo-root <dir>] [--json]`
119
+ Plans the legacy-path migration: lists every knowledge doc a tier clone
120
+ holds at the workspace path (`operator/knowledge/...`), where no registry
121
+ mount reads it, and says what moving it to the mounted path takes.
122
+ Read-only (no vault, no ingest, no network beyond `git fetch` of the
123
+ clones), so it runs in SIMPLE and REMOTE mode. See
124
+ [Legacy-path migration](#legacy-path-migration-library-migrate-mounts).
125
+
126
+ - `library migrate-mounts --apply --tier <name> --out <dir> [--clone <path>]... [--batch-size <n>] [--batches 1|all] [--merged-dir <dir>] [--wait-ms <n>] [--poll-ms <n>]`
127
+ Moves one tier's legacy rows to the mounted path in verified batches
128
+ through the ingest, and stops on the first divergence. REMOTE mode only.
129
+ See [Apply](#apply---apply).
130
+
118
131
  ### Scope rule (verbatim)
119
132
 
120
133
  ```
@@ -136,6 +149,172 @@ Every verb accepts `--help` and `-h` and prints that verb's usage before any
136
149
  config or vault resolution runs, so verb-level help has the same vault-free
137
150
  guarantee as the top-level `library --help`.
138
151
 
152
+ ## Legacy-path migration (`library migrate-mounts`)
153
+
154
+ Writers before 0.1.64 sent knowledge docs to the tier repos at the workspace
155
+ path, so a tier can hold `operator/knowledge/R` (the legacy form, read only
156
+ through the reader's bridge) beside or instead of the mounted form
157
+ (w-2026-10-16-dave-019). `library migrate-mounts` plans the move back.
158
+
159
+ **Clones and registry.** Every clone under the pull-clones root, or the
160
+ `--clone` list, each resolved to its registry key (origin remote, else
161
+ directory name); `--tier` limits the run (a registry key or short name such
162
+ as `hq`). The registry is the company clone among the planned clones, else
163
+ the one `library put` uses (the company clone among
164
+ `LIBRARY_KNOWLEDGE_EXTRA_ROOTS`, else `operator/registry/repos.json`); none
165
+ exits 2 naming every place looked. Each clone is planned at `origin/main`
166
+ (else `HEAD`, or `--ref`) after a `git fetch origin` unless `--no-fetch`;
167
+ the sha is recorded.
168
+
169
+ **Target (tier-preserving).** With R the path after `operator/knowledge/`,
170
+ the tier's one knowledge mount gives the target `<from>R` and the workspace
171
+ id `<to>R`, which the shared mount map's `resolveWrite` must confirm:
172
+
173
+ | Tier | Legacy row | Target | Workspace id |
174
+ |---|---|---|---|
175
+ | hq | `operator/knowledge/x.md` | `knowledge/x.md` | `operator/knowledge/x.md` |
176
+ | hq | `operator/knowledge/writing/x.md` | `knowledge/writing/x.md` (stays in hq) | `operator/knowledge/writing/x.md` |
177
+ | wsd | `operator/knowledge/x.md` | `knowledge/x.md` | `operator/knowledge/wsd/x.md` (the id moves) |
178
+ | operator-barry-private | any | none: no knowledge mount | `owner-decision` |
179
+ | wsd | `operator/knowledge/wsd/R` | `knowledge/R` (same tier, ordinary actions) | `operator/knowledge/wsd/R` (the id stays) |
180
+ | any other | `operator/knowledge/wsd/R` | wsd `knowledge/R` (`cross-tier`) | `operator/knowledge/wsd/R` |
181
+
182
+ A tier with more than one knowledge mount, or a `resolveWrite`
183
+ disagreement, gives `unmappable`.
184
+
185
+ **Cross-tier rows (Dave ruling 2026-10-19).** A legacy row under
186
+ `operator/knowledge/wsd/` is a wsd workspace path, so its target is the one
187
+ `resolveWrite` gives in `stravica-library-wsd` (`knowledge/R`), never the
188
+ tier-preserving nest at `knowledge/wsd/R` (workspace id
189
+ `operator/knowledge/wsd/wsd/R`). Whether it is cross-tier is decided by tier
190
+ identity, not by the prefix: a row is `cross-tier` only when its own tier
191
+ differs from the tier its target belongs to. In the wsd tier such a row is an
192
+ ordinary same-tier row (`re-put`, `delete`, `take-legacy`, `take-mounted`
193
+ or `merge`) with the legacy path as its workspace id. In any other tier it
194
+ gets the action `cross-tier`, and `--apply` never writes it (the planned
195
+ tier's writer may hold no wsd subkey, and the move is the wsd owner's from a
196
+ wsd host). `<dir>/cross-tier.csv` lists every one with
197
+ `sourceTier,legacyPath,legacyBlob,targetTier,targetPath,targetBlob,workspaceId,crossKind,targetSha,reason`;
198
+ `crossKind` is `move` (no wsd copy), `identical`, `merge` (a wsd copy that
199
+ differs; the merge rule applies) or `unknown` (no wsd clone among the
200
+ clones). They still count as legacy rows for `--check`, and a baseline
201
+ `correct` that differs on one is printed as resolved by the ruling, not as
202
+ a disagreement.
203
+
204
+ Open step (not built): once a cross-tier row lands in its target tier (the
205
+ wsd owner applies the move or merge from a wsd host), the source tier's
206
+ legacy copy still needs a `knowledge.doc.delete` in the source tier.
207
+ `--apply` does not do it yet, so `--check` keeps naming the row until that
208
+ delete lands.
209
+
210
+ **Actions**, from the blob shas at the planned sha, never from commit time:
211
+
212
+ | Action | When | Body the apply posts |
213
+ |---|---|---|
214
+ | `re-put` | the mounted path is absent | the legacy body |
215
+ | `delete` | both paths hold the same blob | none (legacy copy deleted) |
216
+ | `take-legacy` | the mounted blob is in the legacy path's history | the legacy body |
217
+ | `take-mounted` | the legacy blob is in the mounted path's history | none (legacy copy deleted) |
218
+ | `merge` | anything else | a merged body, written first |
219
+ | `cross-tier` | the path is under `operator/knowledge/wsd/` in a tier other than wsd | none: never applied, listed in `cross-tier.csv` |
220
+
221
+ **Plan CSV.** `--dry-run --out <dir>` writes `<dir>/plan-<tier>.csv`
222
+ (`<tier>` is the registry key without `stravica-`, for example
223
+ `library-hq`) with the columns
224
+ `tier,current,correct,action,writer,indexed,home,reason,bothExist,legacyBlob,mountedBlob,workspaceId,body,baseline`.
225
+ `writer` is `librarian` when the legacy body's frontmatter has
226
+ `distilledBy:`, else `put`. `indexed` (the legacy id is a node in
227
+ `operator/knowledge/.graph.json`) and `home` (`absent`, `same-as-mounted`,
228
+ `same-as-mispathed` or `differs`, for the home copy at the workspace id)
229
+ need `--repo-root`; otherwise they read `unknown`. With `--baseline <csv>`
230
+ each row is `in-baseline` or `new-since-baseline`, baseline rows no longer
231
+ present count as gone, and a common row whose `correct` differs is printed
232
+ on stderr. One summary line per tier:
233
+
234
+ ```
235
+ library-hq @f3f313e: 548 legacy rows: re-put 520, delete 1, take-legacy 0, take-mounted 0, merge 27, owner-decision 0, unmappable 0; new since baseline 32, gone 0
236
+ ```
237
+
238
+ Without `--out` the CSV (one header for every tier) goes to stdout and the
239
+ summary lines to stderr.
240
+
241
+ **Merge kit.** For every `merge`, `take-legacy` and `take-mounted` row,
242
+ `<dir>/merge/<tier>/<R>.legacy`, `.mounted`, `.diff` (unified, mounted copy
243
+ a/ to legacy copy b/) and `.home` when the home copy matches neither, plus
244
+ `<dir>/merge/README.md`. Merged bodies go to `<merged-dir>/<tier>/<R>`:
245
+ keep the Librarian additions from the legacy copy and the newer operator
246
+ edits from the mounted copy, and resolve conflicts on purpose. Nothing is
247
+ written outside `--out`.
248
+
249
+ **Check.** `--check` exits 0 when every selected tier holds no legacy row
250
+ other than `owner-decision` rows (each named on stderr) and 2 otherwise.
251
+ `--json` prints `{ tiers: [{ repo, ref, sha, legacyCount, byAction,
252
+ ownerDecision: [path], crossTier: [...] }] }`.
253
+
254
+ ### Apply (`--apply`)
255
+
256
+ `library migrate-mounts --apply --tier <name> --out <dir>` moves ONE tier's
257
+ rows to the mounted path through the ingest, as ordinary events. REMOTE mode
258
+ only (SIMPLE exits 1, like every write verb). It needs the company clone
259
+ among the clones (the reader check reads its registry), refuses a tier with
260
+ no knowledge mount (exit 2) and a clone with uncommitted changes, and always
261
+ fetches (`--no-fetch` is refused).
262
+
263
+ Each invocation re-plans the tier live and takes the eligible rows in plan
264
+ order: `re-put`, `delete`, `take-legacy`, `take-mounted`, and `merge` rows
265
+ whose merged body exists at `<merged-dir>/<tier>/<R>`. Every other row
266
+ (`owner-decision`, `unmappable`, `cross-tier`, a `merge` with no merged
267
+ body) is skipped and listed. The first 25 (`--batch-size`, 1 to 100) form
268
+ the batch; `--batches all` repeats until nothing is eligible or a divergence
269
+ stops it. Then, per batch:
270
+
271
+ 1. **Compare-and-swap.** Fetch and re-read both blobs of every batch row;
272
+ any change since the plan is `state-changed`.
273
+ 2. **Upserts.** `knowledge.doc.upsert` with `payload.path` the target,
274
+ `tier` the registry key, `provenance.sourcePath` the workspace id, and
275
+ the legacy body (`re-put`, `take-legacy`) or the merged body (`merge`).
276
+ An ack other than accepted or deduped is `rejected:<reason>`.
277
+ 3. **Wait** until each target blob equals the posted body's blob
278
+ (`--wait-ms`, default 300000; `--poll-ms`, default 10000): a timeout is
279
+ `not-applied`, another blob is `content-mismatch`.
280
+ 4. **Deletes.** `knowledge.doc.delete` of the legacy path verbatim, only for
281
+ rows whose mounted copy verified (phase 3, or phase 1 for `delete` and
282
+ `take-mounted`).
283
+ 5. **Wait** until each legacy path is absent (same bound).
284
+ 6. **Reader check.** Fast-forward the clone, then the reader over an empty
285
+ home (`scanSourcesFor`, `locateScannedFile`) must resolve each workspace
286
+ id to `<clone>/<target>` with the posted (or planned mounted) body's
287
+ sha256; otherwise `reader-mismatch`.
288
+
289
+ The first divergence stops the invocation: no further post of any kind, and
290
+ a legacy copy is never deleted unless its mounted copy verified. Upserted
291
+ docs whose legacy copy is still there are safe (the reader's bridge serves
292
+ the newer mounted copy) and the next run re-plans them. A divergence writes
293
+ `<out>/divergence-<batchId>.json` and exits 2.
294
+
295
+ Event ids are deterministic: a uuid v5 over `migrate-mounts`, the repo key,
296
+ the event action (`upsert` or `delete`), the path, the body's blob sha and
297
+ the last commit that touched the path at the planned sha. A rerun after a
298
+ crash, with the path untouched since, dedups at the ingest, and a second
299
+ full run posts nothing; once the path has changed (the worker wrote another
300
+ body, or a stale writer brought a legacy copy back) the corrective post
301
+ gets a new id rather than being deduped forever.
302
+
303
+ Each batch writes `<out>/batches/<repoKey>-<UTC stamp>.json`: `batchId`,
304
+ `tier`, `ref`, `tierShaBefore`, `tierShaAfter`, `startedAt`, `finishedAt`,
305
+ `rows[]` (`action`, `legacyPath`, `mountedPath`, `workspaceId`,
306
+ `preLegacyBlob`, `preMountedBlob`, `postedBlob`, `mergedBodySha256`,
307
+ `upsertEventId`, `deleteEventId`, `verified: { mounted, legacyGone,
308
+ reader }`, `divergence`), `skipped[]`, `crossTier[]` and `rollback`. There
309
+ is no rollback mode: every pre-state copy is `git show <tierShaBefore>:<path>`
310
+ in the tier; to restore a mounted copy, put that body at its workspace path
311
+ with `library put`; legacy copies are not restored. One line per batch on
312
+ stdout:
313
+
314
+ ```
315
+ library-hq batch stravica-library-hq-20261019T120000000Z: posted 22 upserts, 25 deletes; verified 25 of 25; skipped 30; divergence none
316
+ ```
317
+
139
318
  ## Configuration
140
319
 
141
320
  - `operator/config/preferences.json` (base) and `preferences.local.json`
@@ -15,7 +15,7 @@ import { readFileSync } from 'node:fs';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
 
18
- import { loadLibraryConfig, LibraryConfigError } from './config.mjs';
18
+ import { loadLibraryConfig, LibraryConfigError, readMergedPreferences, resolveDefaultRepoRoot } from './config.mjs';
19
19
  import { runPut } from './verbs/put.mjs';
20
20
  import { runFlush } from './verbs/flush.mjs';
21
21
  import { runGet } from './verbs/get.mjs';
@@ -23,6 +23,7 @@ import { runSearch } from './verbs/search.mjs';
23
23
  import { runStatus } from './verbs/status.mjs';
24
24
  import { runRefreshClones } from './verbs/refresh-clones.mjs';
25
25
  import { runUpMerge } from './verbs/up-merge.mjs';
26
+ import { runMigrateMounts } from './migrate/verb.mjs';
26
27
 
27
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
29
 
@@ -54,6 +55,10 @@ Usage:
54
55
  library status [--json]
55
56
  library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
56
57
  library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]
58
+ library migrate-mounts --dry-run|--check [--tier <name>]... [--clone <path>]... [--ref <ref>]
59
+ [--no-fetch] [--out <dir>] [--baseline <csv>] [--repo-root <dir>] [--json]
60
+ library migrate-mounts --apply --tier <name> --out <dir> [--clone <path>]... [--batch-size <n>]
61
+ [--batches 1|all] [--merged-dir <dir>] [--wait-ms <n>] [--poll-ms <n>]
57
62
  library <verb> -h | --help
58
63
  library -h | --help
59
64
  library -v | --version
@@ -61,8 +66,10 @@ Usage:
61
66
  Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
62
67
  Verbs are only active in REMOTE mode. SIMPLE mode 'library status' prints a
63
68
  one-line mode summary and exits 0; 'library up-merge' is a GitHub-side
64
- maintenance verb and runs in both SIMPLE and REMOTE mode; every other verb
65
- exits 1 in SIMPLE mode with a clean not-applicable message.
69
+ maintenance verb and runs in both SIMPLE and REMOTE mode; 'library
70
+ migrate-mounts --dry-run|--check' is read-only (no vault, no ingest) and runs
71
+ in both modes ('--apply' is REMOTE only); every other verb exits 1 in SIMPLE mode with a clean
72
+ not-applicable message.
66
73
 
67
74
  --help and --version never touch config or the vault, so they always work
68
75
  from a directory with no operator config and no vault installed. Per-verb
@@ -166,6 +173,70 @@ const VERB_USAGE = {
166
173
  left open with a one-time comment (idempotent via an HTML marker) and no
167
174
  merge is ever attempted. Runs in both SIMPLE and REMOTE mode (GitHub-side
168
175
  maintenance verb, no ingest touch). Config in the 'library.upMerge' stanza.
176
+ `,
177
+ 'migrate-mounts': `library migrate-mounts --dry-run|--check [--tier <name>]... [--clone <path>]...
178
+ [--ref <ref>] [--no-fetch] [--out <dir>] [--baseline <csv>]
179
+ [--repo-root <dir>] [--json]
180
+ library migrate-mounts --apply --tier <name> --out <dir> [--clone <path>]...
181
+ [--batch-size <n>] [--batches 1|all] [--merged-dir <dir>]
182
+ [--wait-ms <n>] [--poll-ms <n>] [--ref <ref>]
183
+
184
+ Plans the legacy-path migration (w-2026-10-16-dave-019): every file under
185
+ operator/knowledge/ in a tier clone is a legacy row that no registry mount
186
+ reads. Read-only: no vault, no ingest, no network beyond 'git fetch' of the
187
+ clones; runs in SIMPLE and REMOTE mode.
188
+
189
+ Cross-tier (Dave ruling 2026-10-19): a row under operator/knowledge/wsd/
190
+ is a wsd workspace path, target wsd knowledge/R. Outside the wsd tier it
191
+ is cross-tier: never applied, listed with both blobs in
192
+ <out>/cross-tier.csv. In the wsd tier it takes the ordinary actions, with
193
+ the legacy path as its workspace id.
194
+
195
+ Target (tier-preserving): R is the path after operator/knowledge/; the
196
+ tier's one knowledge mount gives the target <from>R and the workspace id
197
+ <to>R (hq knowledge/R; wsd knowledge/R with id operator/knowledge/wsd/R).
198
+ No knowledge mount: owner-decision. More than one: unmappable.
199
+ Actions, by blob: mounted path absent re-put; same blob delete; mounted
200
+ blob in the legacy path's history take-legacy; legacy blob in the mounted
201
+ path's history take-mounted; anything else merge. Never by commit time.
202
+
203
+ --dry-run Write <out>/plan-<tier>.csv and the merge kit
204
+ (<out>/merge/), and print one summary line per tier.
205
+ Without --out the CSV goes to stdout, summaries to stderr.
206
+ --check Exit 0 when every selected tier holds no legacy row other
207
+ than owner-decision rows (each named on stderr), else 2.
208
+ --tier <name> Repeatable; a registry key or short name (hq, library-wsd).
209
+ --clone <path> Repeatable; the clones to plan. Default: every clone under
210
+ the pull-clones root.
211
+ --ref <ref> Default origin/main when it resolves, else HEAD.
212
+ --no-fetch Skip 'git fetch origin' in each clone.
213
+ --baseline <csv> Mark rows in-baseline or new-since-baseline; count rows
214
+ gone and print any 'correct' disagreement on stderr.
215
+ --repo-root <dir> Fill 'indexed' (operator/knowledge/.graph.json) and 'home'
216
+ (the home copy at the workspace id); else 'unknown'.
217
+ --json Print { tiers: [{ repo, ref, sha, legacyCount, byAction,
218
+ ownerDecision, crossTier }] } instead of the summary lines.
219
+
220
+ --apply REMOTE mode only. Re-plans ONE tier live (with fetch) and
221
+ moves one batch of its eligible rows (re-put, delete,
222
+ take-legacy, take-mounted, merge with a merged body) to
223
+ the mounted path through the ingest: compare-and-swap,
224
+ upserts, wait for each target blob, delete the legacy
225
+ path only behind a verified mounted copy, wait for it to
226
+ go, then the reader must resolve each workspace id to the
227
+ mounted file with the posted body's sha256. The first
228
+ divergence stops the run (exit 2, <out>/divergence-<id>.json).
229
+ Event ids are deterministic (uuid v5), so a rerun dedups.
230
+ Writes <out>/batches/<repo>-<UTC stamp>.json (rows, skipped,
231
+ rollback note) and <out>/cross-tier.csv. Needs the company
232
+ clone among the clones; refuses a tier with no knowledge
233
+ mount (exit 2) and a dirty clone.
234
+ --batch-size <n> Rows per batch, 1 to 100. Default 25.
235
+ --batches 1|all One batch (default) or repeat until nothing is eligible.
236
+ --merged-dir <d> Merged bodies at <d>/<tier>/<R>; a merge row without one
237
+ is skipped.
238
+ --wait-ms <n> Bound per wait phase. Default 300000.
239
+ --poll-ms <n> Poll interval in a wait phase. Default 10000.
169
240
  `,
170
241
  };
171
242
 
@@ -204,6 +275,57 @@ export async function main(argv, seams = {}) {
204
275
  return 0;
205
276
  }
206
277
 
278
+ // `library migrate-mounts --dry-run|--check` is read-only: it needs the
279
+ // preferences (pull-clone root) and the repo root (registry fallback) but
280
+ // never the vault or the ingest, so it is routed before loadLibraryConfig
281
+ // (which resolves a vault-ref remoteKey in REMOTE mode) and therefore
282
+ // before the SIMPLE-mode refusal, as up-merge is.
283
+ if (verb === 'migrate-mounts') {
284
+ let args;
285
+ try {
286
+ args = parseMigrateMounts(rest);
287
+ } catch (e) {
288
+ err(`[library migrate-mounts] ${e.message ?? e}`);
289
+ return 1;
290
+ }
291
+ try {
292
+ const repoRoot = seams.repoRoot ?? resolveDefaultRepoRoot();
293
+ const preferences = readMergedPreferences({
294
+ base: join(repoRoot, 'operator', 'config', 'preferences.json'),
295
+ local: join(repoRoot, 'operator', 'config', 'preferences.local.json'),
296
+ });
297
+ // --apply writes through the ingest: REMOTE mode only, like every
298
+ // other write verb, with the same SIMPLE-mode refusal.
299
+ let config;
300
+ if (args.mode === 'apply') {
301
+ config = loadLibraryConfig({ repoRoot: seams.repoRoot });
302
+ if (config.mode === 'simple') {
303
+ err('[library migrate-mounts] --apply is not applicable in simple mode (library.mode="simple"); '
304
+ + 'it writes through the ingest. --dry-run and --check run in both modes.');
305
+ return 1;
306
+ }
307
+ }
308
+ return await runMigrateMounts({
309
+ args,
310
+ repoRoot,
311
+ preferences,
312
+ env: seams.env ?? process.env,
313
+ log,
314
+ err,
315
+ git: seams.migrateGit,
316
+ remoteOf: seams.remoteOf,
317
+ config,
318
+ fetchImpl: seams.fetchImpl,
319
+ clock: seams.migrateClock,
320
+ sleep: seams.migrateSleep,
321
+ hooks: seams.migrateHooks,
322
+ });
323
+ } catch (e) {
324
+ err(`[library migrate-mounts] ${e.message ?? e}`);
325
+ return typeof e.exitCode === 'number' ? e.exitCode : 2;
326
+ }
327
+ }
328
+
207
329
  let config;
208
330
  try {
209
331
  config = loadLibraryConfig({ repoRoot: seams.repoRoot });
@@ -298,6 +420,66 @@ function parsePut(argv) {
298
420
  verbatimPath: values['verbatim-path'],
299
421
  };
300
422
  }
423
+ function parseMigrateMounts(argv) {
424
+ const { values } = parseArgs({
425
+ args: argv,
426
+ strict: true,
427
+ allowPositionals: false,
428
+ options: {
429
+ 'dry-run': { type: 'boolean', default: false },
430
+ check: { type: 'boolean', default: false },
431
+ apply: { type: 'boolean', default: false },
432
+ tier: { type: 'string', multiple: true },
433
+ clone: { type: 'string', multiple: true },
434
+ ref: { type: 'string' },
435
+ 'no-fetch': { type: 'boolean', default: false },
436
+ out: { type: 'string' },
437
+ baseline: { type: 'string' },
438
+ 'repo-root': { type: 'string' },
439
+ json: { type: 'boolean', default: false },
440
+ 'batch-size': { type: 'string' },
441
+ batches: { type: 'string' },
442
+ 'merged-dir': { type: 'string' },
443
+ 'wait-ms': { type: 'string' },
444
+ 'poll-ms': { type: 'string' },
445
+ },
446
+ });
447
+ const modes = ['dry-run', 'check', 'apply'].filter((m) => values[m]);
448
+ if (modes.length !== 1) {
449
+ throw new Error('exactly one of --dry-run, --check or --apply is required');
450
+ }
451
+ const intFlag = (name, min, max) => {
452
+ const raw = values[name];
453
+ if (raw === undefined) return undefined;
454
+ const n = Number(raw);
455
+ if (!/^\d+$/.test(raw) || n < min || n > max) throw new Error(`--${name} must be an integer from ${min} to ${max}`);
456
+ return n;
457
+ };
458
+ const applyOnly = ['batch-size', 'batches', 'merged-dir', 'wait-ms', 'poll-ms'].filter((k) => values[k] !== undefined);
459
+ if (modes[0] !== 'apply' && applyOnly.length) throw new Error(`--${applyOnly[0]} is for --apply only`);
460
+ if (modes[0] === 'apply') {
461
+ if ((values.tier ?? []).length !== 1) throw new Error('--apply needs exactly one --tier');
462
+ if (!values.out) throw new Error('--apply needs --out <dir>');
463
+ if (values['no-fetch']) throw new Error('--apply always fetches; --no-fetch is for --dry-run and --check');
464
+ if (values.batches !== undefined && !['1', 'all'].includes(values.batches)) throw new Error('--batches must be 1 or all');
465
+ }
466
+ return {
467
+ mode: modes[0],
468
+ tiers: values.tier ?? [],
469
+ clones: values.clone ?? [],
470
+ ref: values.ref,
471
+ fetch: !values['no-fetch'],
472
+ out: values.out,
473
+ baseline: values.baseline,
474
+ repoRoot: values['repo-root'],
475
+ json: values.json,
476
+ batchSize: intFlag('batch-size', 1, 100),
477
+ batches: values.batches ?? '1',
478
+ mergedDir: values['merged-dir'],
479
+ waitMs: intFlag('wait-ms', 1, 3600000),
480
+ pollMs: intFlag('poll-ms', 1, 600000),
481
+ };
482
+ }
301
483
  function parseFlush(argv) {
302
484
  parseArgs({ args: argv, strict: true, allowPositionals: true, options: {} });
303
485
  return {};
@@ -149,6 +149,10 @@ export function guessKindFromPath(sourcePath) {
149
149
  * w-2026-10-16-dave-019 slice 2). `provenance.sourcePath` keeps the
150
150
  * repo-relative source path. Absent: `payload.path` is the source path,
151
151
  * unchanged.
152
+ * @param {string} [args.clientEventId] a caller-chosen event id (a uuid)
153
+ * for an idempotent rerun: `library migrate-mounts --apply` derives one
154
+ * per row so the ingest dedups a repeat post (w-2026-10-16-dave-019 slice
155
+ * 3). Absent: a fresh `randomUUID()`, unchanged.
152
156
  * @param {() => Date} [args.clock] test seam
153
157
  * @returns {object} EventInput ready for POST /events
154
158
  */
@@ -162,6 +166,7 @@ export function buildEnvelope({
162
166
  sourceKind = 'manual',
163
167
  source,
164
168
  payloadPath,
169
+ clientEventId,
165
170
  clock = () => new Date(),
166
171
  }) {
167
172
  // Repo-relative resolution + refusal for anything that would encode as
@@ -274,7 +279,7 @@ export function buildEnvelope({
274
279
  }
275
280
 
276
281
  const envelope = {
277
- clientEventId: randomUUID(),
282
+ clientEventId: clientEventId ?? randomUUID(),
278
283
  occurredAt: now.toISOString(),
279
284
  producer: {
280
285
  operator: producer.operator,