@stravica-ai/digital-operator-pa 0.1.63 → 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.
@@ -14,27 +14,54 @@ pre-remote library sweep.
14
14
 
15
15
  ## Verbs
16
16
 
17
- - `library put <path> [--kind <kind>] [--tier auto|<tier>]`
17
+ - `library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]`
18
18
  Streams a single event to `POST /events`.
19
19
  **`--kind` is required** for any path outside `agents/*/staging/`; those
20
20
  staging paths default to `knowledge.observation.staged` via path inference.
21
- A canonical doc write is `library put <absolute path> --kind knowledge.doc.upsert`.
21
+ A canonical doc write is `library put <path> --kind knowledge.doc.upsert`.
22
22
  Known kinds (ingest will 422 anything else): `knowledge.observation.staged`,
23
23
  `knowledge.doc.upsert`, `knowledge.doc.delete`, `profile.upsert`,
24
24
  `profile.slice.upsert`, `preferences.upsert`, `role.output`,
25
25
  `system.correction`, `synthesis.upsert`, `synthesis.merge`,
26
26
  `synthesis.retire`, `system.seed`.
27
- **Path resolution.** The `<path>` argument is read as given (cwd-relative or
28
- absolute); the repo-relative payload path is derived only when the input is
29
- absolute. A relative path run from `agents/dave/` therefore encodes a
30
- payload path relative to `agents/dave/`, which also misses the
31
- `agents/*/staging/` kind-inference regex. Safe form: pass an absolute path,
32
- or run from the repo root.
27
+ **Path resolution.** `<path>` is resolved against the current directory, so
28
+ a relative path from any cwd and an absolute path encode the same
29
+ repo-relative path. From the repo root this is byte-identical to the old
30
+ "as given" encoding.
31
+ **Knowledge docs go to the tier path a mount reads.** For
32
+ `knowledge.doc.upsert` and `knowledge.doc.delete` the workspace path
33
+ (`operator/knowledge/...`) is mapped through the registry knowledge mounts
34
+ (`repos.<repo>.mounts[]` with `to` under `operator/knowledge/`) by the
35
+ shared mount map vendored from remote-library
36
+ (`packages/core/src/knowledge/retrieval/mount-map.mjs`). The longest
37
+ matching mount `to` wins:
38
+ | Workspace path | Sent as | Tier |
39
+ |---|---|---|
40
+ | `operator/knowledge/engineering/x.md` | `knowledge/engineering/x.md` | `auto` (hq and company agree) |
41
+ | `operator/knowledge/wsd/memory/y.md` | `knowledge/memory/y.md` | `stravica-library-wsd` |
42
+ | `operator/knowledge/writing/z.md` | `voice/z.md` | `stravica-operator-barry` |
43
+ A unique tier is stamped exactly as an explicit `--tier` is (envelope `tier`
44
+ and `payload.source`); `provenance.sourcePath` keeps the workspace path.
45
+ With an explicit `--tier` (registry key or short name such as `hq`), only
46
+ that tier's mounts are tried, and the registry key is stamped. Put refuses
47
+ with exit 2 and one line naming the mounts tried when no knowledge mount
48
+ reads the path, when the candidate tiers disagree, when `--tier` names an
49
+ unknown tier or one that cannot read the path, or when no registry is found.
50
+ The ingest refuses the same shape with 422 `unmapped-path`.
51
+ Paths the profile tier map owns (`operator/profile/**`) keep their profile
52
+ routing and are sent unmapped.
53
+ **Registry source.** The company clone among `LIBRARY_KNOWLEDGE_EXTRA_ROOTS`
54
+ (the clone whose own registry entry mounts `registry/`), else the workspace
55
+ copy `<repoRoot>/operator/registry/repos.json`, else none (refuse).
33
56
  `--tier auto` sends the spec §2.3 sentinel; the server's routing engine
34
57
  resolves the actual tier. An explicit `--tier <repo>` also stamps
35
58
  `payload.source = <repo>` on the kinds whose ingest schema accepts it
36
59
  (`knowledge.doc.upsert`, `knowledge.doc.delete`, `profile.upsert`,
37
60
  `profile.slice.upsert`).
61
+ `--verbatim-path` (slice 3 removes) sends a `knowledge.doc.delete` at the
62
+ workspace path unmapped, to delete the legacy `operator/knowledge/...`
63
+ copies. It needs `--kind knowledge.doc.delete` and an explicit `--tier`;
64
+ any other use is a usage error (exit 1).
38
65
 
39
66
  - `library flush`
40
67
  Sweeps `<repoRoot>/agents/<paName>/staging/*` and streams one event per file.
@@ -88,6 +115,19 @@ pre-remote library sweep.
88
115
  Runs the same in SIMPLE and REMOTE mode (GitHub-side maintenance verb,
89
116
  no ingest touch). Config in the `library.upMerge` stanza; see below.
90
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
+
91
131
  ### Scope rule (verbatim)
92
132
 
93
133
  ```
@@ -109,6 +149,172 @@ Every verb accepts `--help` and `-h` and prints that verb's usage before any
109
149
  config or vault resolution runs, so verb-level help has the same vault-free
110
150
  guarantee as the top-level `library --help`.
111
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
+
112
318
  ## Configuration
113
319
 
114
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
@@ -78,7 +85,7 @@ help ('library put --help' etc.) is served the same way.
78
85
  * useful at the point of friction, not a prose essay.
79
86
  */
80
87
  const VERB_USAGE = {
81
- put: `library put <path> [--kind <kind>] [--tier auto|<tier>]
88
+ put: `library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]
82
89
 
83
90
  Streams a single event to POST /events (REMOTE mode only).
84
91
 
@@ -91,12 +98,23 @@ const VERB_USAGE = {
91
98
  synthesis.upsert, synthesis.merge, synthesis.retire,
92
99
  system.seed.
93
100
  --tier <name> 'auto' (default) lets the server route; an explicit tier
94
- (repo name) stamps payload.source for routing-rule kinds.
101
+ (repo name, or a short name such as 'hq') stamps
102
+ payload.source for routing-rule kinds.
103
+ --verbatim-path With --kind knowledge.doc.delete and an explicit --tier
104
+ only: send the workspace path unmapped, to delete a legacy
105
+ operator/knowledge/... copy (slice 3 removes).
95
106
 
96
- Path resolution: the argument is read as given (cwd-relative or absolute);
97
- the repo-relative payload path is derived only when the input is absolute.
98
- Run from the repo root with a relative path, or always pass an absolute
99
- path, to keep the staging-inference and payload path in agreement.
107
+ Path resolution: <path> is resolved against the current directory, so the
108
+ payload path is repo-relative from any cwd.
109
+
110
+ Knowledge docs (knowledge.doc.upsert, knowledge.doc.delete) are sent at the
111
+ tier path a registry knowledge mount reads, for example
112
+ operator/knowledge/x.md -> knowledge/x.md (tier auto),
113
+ operator/knowledge/wsd/x.md -> knowledge/x.md (stravica-library-wsd),
114
+ operator/knowledge/writing/x.md -> voice/x.md (stravica-operator-barry).
115
+ No mount reads the path, the tiers disagree, or no registry: exit 2 with
116
+ the mounts tried. Registry: the company clone among
117
+ LIBRARY_KNOWLEDGE_EXTRA_ROOTS, else operator/registry/repos.json.
100
118
  `,
101
119
  flush: `library flush
102
120
 
@@ -155,6 +173,70 @@ const VERB_USAGE = {
155
173
  left open with a one-time comment (idempotent via an HTML marker) and no
156
174
  merge is ever attempted. Runs in both SIMPLE and REMOTE mode (GitHub-side
157
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.
158
240
  `,
159
241
  };
160
242
 
@@ -193,6 +275,57 @@ export async function main(argv, seams = {}) {
193
275
  return 0;
194
276
  }
195
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
+
196
329
  let config;
197
330
  try {
198
331
  config = loadLibraryConfig({ repoRoot: seams.repoRoot });
@@ -276,9 +409,76 @@ function parsePut(argv) {
276
409
  options: {
277
410
  kind: { type: 'string' },
278
411
  tier: { type: 'string', default: 'auto' },
412
+ // slice 3 removes: deletes the legacy operator/knowledge/... copies.
413
+ 'verbatim-path': { type: 'boolean', default: false },
414
+ },
415
+ });
416
+ return {
417
+ path: positionals[0],
418
+ kind: values.kind,
419
+ tier: values.tier,
420
+ verbatimPath: values['verbatim-path'],
421
+ };
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' },
279
445
  },
280
446
  });
281
- return { path: positionals[0], kind: values.kind, tier: values.tier };
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
+ };
282
482
  }
283
483
  function parseFlush(argv) {
284
484
  parseArgs({ args: argv, strict: true, allowPositionals: true, options: {} });
@@ -143,6 +143,16 @@ export function guessKindFromPath(sourcePath) {
143
143
  * `--tier` is asserted explicitly (not the `auto` sentinel), the tier
144
144
  * value is used as the source stamp for these kinds automatically, so
145
145
  * callers rarely pass this directly.
146
+ * @param {string} [args.payloadPath] the tier path to send as
147
+ * `payload.path` when it differs from the repo-relative source path
148
+ * (`library put` maps knowledge docs through the registry mounts,
149
+ * w-2026-10-16-dave-019 slice 2). `provenance.sourcePath` keeps the
150
+ * repo-relative source path. Absent: `payload.path` is the source path,
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.
146
156
  * @param {() => Date} [args.clock] test seam
147
157
  * @returns {object} EventInput ready for POST /events
148
158
  */
@@ -155,6 +165,8 @@ export function buildEnvelope({
155
165
  tier = 'auto',
156
166
  sourceKind = 'manual',
157
167
  source,
168
+ payloadPath,
169
+ clientEventId,
158
170
  clock = () => new Date(),
159
171
  }) {
160
172
  // Repo-relative resolution + refusal for anything that would encode as
@@ -173,6 +185,10 @@ export function buildEnvelope({
173
185
  if (relPath !== undefined) {
174
186
  assertRepoRelativeSafe(relPath, sourcePath, repoRoot);
175
187
  }
188
+ if (payloadPath !== undefined) {
189
+ assertRepoRelativeSafe(payloadPath, sourcePath, repoRoot);
190
+ }
191
+ const wirePath = payloadPath ?? relPath;
176
192
 
177
193
  const resolvedKind = kind ?? guessKindFromPath(relPath ?? sourcePath ?? '') ?? null;
178
194
  if (!resolvedKind) {
@@ -218,7 +234,7 @@ export function buildEnvelope({
218
234
  let payload;
219
235
  if (resolvedKind === 'knowledge.doc.delete') {
220
236
  payload = {
221
- path: relPath ?? null,
237
+ path: wirePath ?? null,
222
238
  ...(stampSource ? { source: effectiveSource } : {}),
223
239
  };
224
240
  } else if (resolvedKind === 'profile.slice.upsert') {
@@ -249,7 +265,7 @@ export function buildEnvelope({
249
265
  } else if (KINDS_ACCEPTING_SOURCE.has(resolvedKind)) {
250
266
  // knowledge.doc.upsert, profile.upsert.
251
267
  payload = {
252
- path: relPath ?? null,
268
+ path: wirePath ?? null,
253
269
  content,
254
270
  ...(stampSource ? { source: effectiveSource } : {}),
255
271
  };
@@ -257,13 +273,13 @@ export function buildEnvelope({
257
273
  // Every other kind keeps the pre-cure {path, content} shape
258
274
  // byte-identical.
259
275
  payload = {
260
- path: relPath ?? null,
276
+ path: wirePath ?? null,
261
277
  content,
262
278
  };
263
279
  }
264
280
 
265
281
  const envelope = {
266
- clientEventId: randomUUID(),
282
+ clientEventId: clientEventId ?? randomUUID(),
267
283
  occurredAt: now.toISOString(),
268
284
  producer: {
269
285
  operator: producer.operator,