@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.
- package/dist/engineering-pa-cli/README.md +7 -5
- package/dist/engineering-pa-core/src/knowledge/retrieval/links.mjs +88 -24
- package/dist/engineering-pa-core/src/knowledge/retrieval/mount-map.mjs +169 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/mounts.mjs +62 -9
- package/dist/engineering-pa-library-cli/README.md +214 -8
- package/dist/engineering-pa-library-cli/src/cli.mjs +210 -10
- package/dist/engineering-pa-library-cli/src/envelope.mjs +20 -4
- package/dist/engineering-pa-library-cli/src/migrate/apply.mjs +479 -0
- package/dist/engineering-pa-library-cli/src/migrate/kit.mjs +108 -0
- package/dist/engineering-pa-library-cli/src/migrate/plan.mjs +593 -0
- package/dist/engineering-pa-library-cli/src/migrate/verb.mjs +222 -0
- package/dist/engineering-pa-library-cli/src/verbs/put.mjs +97 -14
- package/package.json +1 -1
|
@@ -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 <
|
|
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.**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
|
65
|
-
|
|
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
|
|
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:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|