# library-cli

REMOTE-mode client for the `@stravica-ai/remote-library` service (spec §3.3).
Ships the `library` bin with seven verbs: `put`, `flush`, `get`, `search`,
`status`, `refresh-clones`, and `up-merge`.

Only active when `operator/config/preferences.json` has `library.mode: "remote"`
plus `library.remoteEndpoint` and `library.remoteKey`. SIMPLE-mode installs (or
unset `library.mode`) run two verbs: `library status` prints a one-line mode
summary and exits 0, and `library up-merge` is a GitHub-side maintenance verb
that runs the same in both modes. Every other verb exits 1 in SIMPLE mode with
a clean not-applicable message; the CLI is deliberately not a shim for the
pre-remote library sweep.

## Verbs

- `library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]`
  Streams a single event to `POST /events`.
  **`--kind` is required** for any path outside `agents/*/staging/`; those
  staging paths default to `knowledge.observation.staged` via path inference.
  A canonical doc write is `library put <path> --kind knowledge.doc.upsert`.
  Known kinds (ingest will 422 anything else): `knowledge.observation.staged`,
  `knowledge.doc.upsert`, `knowledge.doc.delete`, `profile.upsert`,
  `profile.slice.upsert`, `preferences.upsert`, `role.output`,
  `system.correction`, `synthesis.upsert`, `synthesis.merge`,
  `synthesis.retire`, `system.seed`.
  **Path resolution.** `<path>` is resolved against the current directory, so
  a relative path from any cwd and an absolute path encode the same
  repo-relative path. From the repo root this is byte-identical to the old
  "as given" encoding.
  **Knowledge docs go to the tier path a mount reads.** For
  `knowledge.doc.upsert` and `knowledge.doc.delete` the workspace path
  (`operator/knowledge/...`) is mapped through the registry knowledge mounts
  (`repos.<repo>.mounts[]` with `to` under `operator/knowledge/`) by the
  shared mount map vendored from remote-library
  (`packages/core/src/knowledge/retrieval/mount-map.mjs`). The longest
  matching mount `to` wins:
  | Workspace path | Sent as | Tier |
  |---|---|---|
  | `operator/knowledge/engineering/x.md` | `knowledge/engineering/x.md` | `auto` (hq and company agree) |
  | `operator/knowledge/wsd/memory/y.md` | `knowledge/memory/y.md` | `stravica-library-wsd` |
  | `operator/knowledge/writing/z.md` | `voice/z.md` | `stravica-operator-barry` |
  A unique tier is stamped exactly as an explicit `--tier` is (envelope `tier`
  and `payload.source`); `provenance.sourcePath` keeps the workspace path.
  With an explicit `--tier` (registry key or short name such as `hq`), only
  that tier's mounts are tried, and the registry key is stamped. Put refuses
  with exit 2 and one line naming the mounts tried when no knowledge mount
  reads the path, when the candidate tiers disagree, when `--tier` names an
  unknown tier or one that cannot read the path, or when no registry is found.
  The ingest refuses the same shape with 422 `unmapped-path`.
  Paths the profile tier map owns (`operator/profile/**`) keep their profile
  routing and are sent unmapped.
  **Registry source.** The company clone among `LIBRARY_KNOWLEDGE_EXTRA_ROOTS`
  (the clone whose own registry entry mounts `registry/`), else the workspace
  copy `<repoRoot>/operator/registry/repos.json`, else none (refuse).
  `--tier auto` sends the spec §2.3 sentinel; the server's routing engine
  resolves the actual tier. An explicit `--tier <repo>` also stamps
  `payload.source = <repo>` on the kinds whose ingest schema accepts it
  (`knowledge.doc.upsert`, `knowledge.doc.delete`, `profile.upsert`,
  `profile.slice.upsert`).
  `--verbatim-path` (slice 3 removes) sends a `knowledge.doc.delete` at the
  workspace path unmapped, to delete the legacy `operator/knowledge/...`
  copies. It needs `--kind knowledge.doc.delete` and an explicit `--tier`;
  any other use is a usage error (exit 1).

- `library flush`
  Sweeps `<repoRoot>/agents/<paName>/staging/*` and streams one event per file.
  Archives to `staging/_archived/` on `accepted` or `deduped` (both count as
  ack per spec §4.2). Failures leave the file in place; next flush retries
  (idempotent via `clientEventId`).

- `library get <path>`
  Reads from the local pulled clone (no service round-trip, spec §3.3).
  `<path>` is resolved under `LIBRARY_PULL_CLONES_ROOT` (env; else
  `library.pullClonesRoot` in preferences; else `~/stravica-library-clones/`),
  and MUST begin with the tier clone directory, for example
  `stravica-library-hq/operator/knowledge/knowledge-memory/supersession-and-retrieval-order.md`.
  The repo-relative form (without the tier prefix) fails with ENOENT because
  the file does not exist at that path under the pull-clones root.

- `library search <query> [--files-only]`
  Filesystem-native substring search over the pull-clones root. Exits 1 with
  no output when nothing matches (grep convention).

- `library status [--json]`
  GETs `/status` from the service (or `library.statusEndpoint` when the
  install splits ingest and status onto separate services), plus
  `git rev-list HEAD..origin/main --count` per tier clone. The compact form
  prints service reachability, worker lag, librarian-consumer state, perHost
  put counts, and one line per tier clone. `--json` emits the combined
  structure; top-level keys are `endpoint`, `statusEndpoint`, `service`
  (`httpStatus` + `body`), `serviceError`, and `pullClones` (`root` +
  `tiers[]`). `service.body` includes the worker offset/lag block,
  `librarianConsumer`, `perHost`, per-repo push state, ingest stats,
  capped-collection usage and backup age (whatever the service version
  serves; older services may omit fields).

- `library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...`
  Session-boot verb: `git fetch` then fast-forward `git merge origin/main`
  per tier clone under the pull-clones root. Refuses to touch a clone with
  uncommitted local changes (pull-clones are read-only projections; a local
  edit is a corruption signal, not a merge target). Exit 0 iff every tier
  refreshed; any per-tier failure surfaces on stderr and returns 2.
  `--tier <name>` is repeatable and limits the run to the named clones.
  `--timeout-ms` sets the per-tier git command timeout (default 20000).

- `library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]`
  Scheduled auto-merger for library up-path PRs. Scans configured library
  repos for open PRs from branch `library-sync-up/<device>` and merges each
  one whose diff is confined to the paths `<device>` owns per
  `operator/registry/repos.json`. Anything else is a refusal: the PR is
  left open with a one-time comment (idempotent via an HTML marker) and no
  merge is ever attempted. Writes one JSONL line per PR per pass to
  `library.upMerge.eventsLog` (default `.cache/library-up-merger/events.jsonl`).
  Runs the same in SIMPLE and REMOTE mode (GitHub-side maintenance verb,
  no ingest touch). Config in the `library.upMerge` stanza; see below.

- `library migrate-mounts --dry-run|--check [--tier <name>]... [--clone <path>]... [--ref <ref>] [--no-fetch] [--out <dir>] [--baseline <csv>] [--repo-root <dir>] [--json]`
  Plans the legacy-path migration: lists every knowledge doc a tier clone
  holds at the workspace path (`operator/knowledge/...`), where no registry
  mount reads it, and says what moving it to the mounted path takes.
  Read-only (no vault, no ingest, no network beyond `git fetch` of the
  clones), so it runs in SIMPLE and REMOTE mode. See
  [Legacy-path migration](#legacy-path-migration-library-migrate-mounts).

- `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>]`
  Moves one tier's legacy rows to the mounted path in verified batches
  through the ingest, and stops on the first divergence. REMOTE mode only.
  See [Apply](#apply---apply).

### Scope rule (verbatim)

```
A PR is eligible when both:
  (1) its head branch matches "library-sync-up/<device>"
  (2) every changed file's repo-relative path falls under a path <device>
      owns for that library repo per operator/registry/repos.json:
        for each mount M where M.upPath === true and M.upPathOwner === <device>:
          allowed prefix = M.from + include, for each include in M.upPathInclude
        a file matches when its path equals a prefix (single-file include)
        or starts with prefix + "/" (directory include).
Any file outside the device's allowed prefixes is a refusal - the PR is
left open, a one-time comment is posted, and no merge is ever attempted.
```

### Per-verb help

Every verb accepts `--help` and `-h` and prints that verb's usage before any
config or vault resolution runs, so verb-level help has the same vault-free
guarantee as the top-level `library --help`.

## Legacy-path migration (`library migrate-mounts`)

Writers before 0.1.64 sent knowledge docs to the tier repos at the workspace
path, so a tier can hold `operator/knowledge/R` (the legacy form, read only
through the reader's bridge) beside or instead of the mounted form
(w-2026-10-16-dave-019). `library migrate-mounts` plans the move back.

**Clones and registry.** Every clone under the pull-clones root, or the
`--clone` list, each resolved to its registry key (origin remote, else
directory name); `--tier` limits the run (a registry key or short name such
as `hq`). The registry is the company clone among the planned clones, else
the one `library put` uses (the company clone among
`LIBRARY_KNOWLEDGE_EXTRA_ROOTS`, else `operator/registry/repos.json`); none
exits 2 naming every place looked. Each clone is planned at `origin/main`
(else `HEAD`, or `--ref`) after a `git fetch origin` unless `--no-fetch`;
the sha is recorded.

**Target (tier-preserving).** With R the path after `operator/knowledge/`,
the tier's one knowledge mount gives the target `<from>R` and the workspace
id `<to>R`, which the shared mount map's `resolveWrite` must confirm:

| Tier | Legacy row | Target | Workspace id |
|---|---|---|---|
| hq | `operator/knowledge/x.md` | `knowledge/x.md` | `operator/knowledge/x.md` |
| hq | `operator/knowledge/writing/x.md` | `knowledge/writing/x.md` (stays in hq) | `operator/knowledge/writing/x.md` |
| wsd | `operator/knowledge/x.md` | `knowledge/x.md` | `operator/knowledge/wsd/x.md` (the id moves) |
| operator-barry-private | any | none: no knowledge mount | `owner-decision` |
| wsd | `operator/knowledge/wsd/R` | `knowledge/R` (same tier, ordinary actions) | `operator/knowledge/wsd/R` (the id stays) |
| any other | `operator/knowledge/wsd/R` | wsd `knowledge/R` (`cross-tier`) | `operator/knowledge/wsd/R` |

A tier with more than one knowledge mount, or a `resolveWrite`
disagreement, gives `unmappable`.

**Cross-tier rows (Dave ruling 2026-10-19).** A legacy row under
`operator/knowledge/wsd/` is a wsd workspace path, so its target is the one
`resolveWrite` gives in `stravica-library-wsd` (`knowledge/R`), never the
tier-preserving nest at `knowledge/wsd/R` (workspace id
`operator/knowledge/wsd/wsd/R`). Whether it is cross-tier is decided by tier
identity, not by the prefix: a row is `cross-tier` only when its own tier
differs from the tier its target belongs to. In the wsd tier such a row is an
ordinary same-tier row (`re-put`, `delete`, `take-legacy`, `take-mounted`
or `merge`) with the legacy path as its workspace id. In any other tier it
gets the action `cross-tier`, and `--apply` never writes it (the planned
tier's writer may hold no wsd subkey, and the move is the wsd owner's from a
wsd host). `<dir>/cross-tier.csv` lists every one with
`sourceTier,legacyPath,legacyBlob,targetTier,targetPath,targetBlob,workspaceId,crossKind,targetSha,reason`;
`crossKind` is `move` (no wsd copy), `identical`, `merge` (a wsd copy that
differs; the merge rule applies) or `unknown` (no wsd clone among the
clones). They still count as legacy rows for `--check`, and a baseline
`correct` that differs on one is printed as resolved by the ruling, not as
a disagreement.

Open step (not built): once a cross-tier row lands in its target tier (the
wsd owner applies the move or merge from a wsd host), the source tier's
legacy copy still needs a `knowledge.doc.delete` in the source tier.
`--apply` does not do it yet, so `--check` keeps naming the row until that
delete lands.

**Actions**, from the blob shas at the planned sha, never from commit time:

| Action | When | Body the apply posts |
|---|---|---|
| `re-put` | the mounted path is absent | the legacy body |
| `delete` | both paths hold the same blob | none (legacy copy deleted) |
| `take-legacy` | the mounted blob is in the legacy path's history | the legacy body |
| `take-mounted` | the legacy blob is in the mounted path's history | none (legacy copy deleted) |
| `merge` | anything else | a merged body, written first |
| `cross-tier` | the path is under `operator/knowledge/wsd/` in a tier other than wsd | none: never applied, listed in `cross-tier.csv` |

**Plan CSV.** `--dry-run --out <dir>` writes `<dir>/plan-<tier>.csv`
(`<tier>` is the registry key without `stravica-`, for example
`library-hq`) with the columns
`tier,current,correct,action,writer,indexed,home,reason,bothExist,legacyBlob,mountedBlob,workspaceId,body,baseline`.
`writer` is `librarian` when the legacy body's frontmatter has
`distilledBy:`, else `put`. `indexed` (the legacy id is a node in
`operator/knowledge/.graph.json`) and `home` (`absent`, `same-as-mounted`,
`same-as-mispathed` or `differs`, for the home copy at the workspace id)
need `--repo-root`; otherwise they read `unknown`. With `--baseline <csv>`
each row is `in-baseline` or `new-since-baseline`, baseline rows no longer
present count as gone, and a common row whose `correct` differs is printed
on stderr. One summary line per tier:

```
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
```

Without `--out` the CSV (one header for every tier) goes to stdout and the
summary lines to stderr.

**Merge kit.** For every `merge`, `take-legacy` and `take-mounted` row,
`<dir>/merge/<tier>/<R>.legacy`, `.mounted`, `.diff` (unified, mounted copy
a/ to legacy copy b/) and `.home` when the home copy matches neither, plus
`<dir>/merge/README.md`. Merged bodies go to `<merged-dir>/<tier>/<R>`:
keep the Librarian additions from the legacy copy and the newer operator
edits from the mounted copy, and resolve conflicts on purpose. Nothing is
written outside `--out`.

**Check.** `--check` exits 0 when every selected tier holds no legacy row
other than `owner-decision` rows (each named on stderr) and 2 otherwise.
`--json` prints `{ tiers: [{ repo, ref, sha, legacyCount, byAction,
ownerDecision: [path], crossTier: [...] }] }`.

### Apply (`--apply`)

`library migrate-mounts --apply --tier <name> --out <dir>` moves ONE tier's
rows to the mounted path through the ingest, as ordinary events. REMOTE mode
only (SIMPLE exits 1, like every write verb). It needs the company clone
among the clones (the reader check reads its registry), refuses a tier with
no knowledge mount (exit 2) and a clone with uncommitted changes, and always
fetches (`--no-fetch` is refused).

Each invocation re-plans the tier live and takes the eligible rows in plan
order: `re-put`, `delete`, `take-legacy`, `take-mounted`, and `merge` rows
whose merged body exists at `<merged-dir>/<tier>/<R>`. Every other row
(`owner-decision`, `unmappable`, `cross-tier`, a `merge` with no merged
body) is skipped and listed. The first 25 (`--batch-size`, 1 to 100) form
the batch; `--batches all` repeats until nothing is eligible or a divergence
stops it. Then, per batch:

1. **Compare-and-swap.** Fetch and re-read both blobs of every batch row;
   any change since the plan is `state-changed`.
2. **Upserts.** `knowledge.doc.upsert` with `payload.path` the target,
   `tier` the registry key, `provenance.sourcePath` the workspace id, and
   the legacy body (`re-put`, `take-legacy`) or the merged body (`merge`).
   An ack other than accepted or deduped is `rejected:<reason>`.
3. **Wait** until each target blob equals the posted body's blob
   (`--wait-ms`, default 300000; `--poll-ms`, default 10000): a timeout is
   `not-applied`, another blob is `content-mismatch`.
4. **Deletes.** `knowledge.doc.delete` of the legacy path verbatim, only for
   rows whose mounted copy verified (phase 3, or phase 1 for `delete` and
   `take-mounted`).
5. **Wait** until each legacy path is absent (same bound).
6. **Reader check.** Fast-forward the clone, then the reader over an empty
   home (`scanSourcesFor`, `locateScannedFile`) must resolve each workspace
   id to `<clone>/<target>` with the posted (or planned mounted) body's
   sha256; otherwise `reader-mismatch`.

The first divergence stops the invocation: no further post of any kind, and
a legacy copy is never deleted unless its mounted copy verified. Upserted
docs whose legacy copy is still there are safe (the reader's bridge serves
the newer mounted copy) and the next run re-plans them. A divergence writes
`<out>/divergence-<batchId>.json` and exits 2.

Event ids are deterministic: a uuid v5 over `migrate-mounts`, the repo key,
the event action (`upsert` or `delete`), the path, the body's blob sha and
the last commit that touched the path at the planned sha. A rerun after a
crash, with the path untouched since, dedups at the ingest, and a second
full run posts nothing; once the path has changed (the worker wrote another
body, or a stale writer brought a legacy copy back) the corrective post
gets a new id rather than being deduped forever.

Each batch writes `<out>/batches/<repoKey>-<UTC stamp>.json`: `batchId`,
`tier`, `ref`, `tierShaBefore`, `tierShaAfter`, `startedAt`, `finishedAt`,
`rows[]` (`action`, `legacyPath`, `mountedPath`, `workspaceId`,
`preLegacyBlob`, `preMountedBlob`, `postedBlob`, `mergedBodySha256`,
`upsertEventId`, `deleteEventId`, `verified: { mounted, legacyGone,
reader }`, `divergence`), `skipped[]`, `crossTier[]` and `rollback`. There
is no rollback mode: every pre-state copy is `git show <tierShaBefore>:<path>`
in the tier; to restore a mounted copy, put that body at its workspace path
with `library put`; legacy copies are not restored. One line per batch on
stdout:

```
library-hq batch stravica-library-hq-20261019T120000000Z: posted 22 upserts, 25 deletes; verified 25 of 25; skipped 30; divergence none
```

## Configuration

- `operator/config/preferences.json` (base) and `preferences.local.json`
  (device-local overlay; shallow-merged on top):

  ```json
  {
    "library": {
      "mode": "remote",
      "remoteEndpoint": "https://ops-01.internal",
      "remoteKey": "sk_barry_dave_deadbeef:secret"
    }
  }
  ```

- `LIBRARY_PULL_CLONES_ROOT` env: overrides the pull-clones root at runtime.

- `library.upMerge` stanza in preferences (used by `library up-merge` only):

  ```json
  {
    "library": {
      "upMerge": {
        "repos": ["Stravica/stravica-library-wsd"],
        "registryPath": "operator/registry/repos.json",
        "eventsLog": ".cache/library-up-merger/events.jsonl",
        "mergeMethod": "merge",
        "branchPrefix": "library-sync-up/",
        "deleteBranchOnMerge": true,
        "ghBin": "gh"
      }
    }
  }
  ```

  Every field has a safe default; `repos` defaults to `[]` (no repos scanned)
  and can be overridden per invocation with `--repo <owner/name>` (repeatable).

### Migrating from the HQ `library-up-merger` tool

`library up-merge` is a behaviour-identical promotion of the HQ tool at
`operator/extensions/tools/library-up-merger/`. The scheduled command
changes from
`node operator/extensions/tools/library-up-merger/bin/library-up-merger`
to `pnpm exec library up-merge`. The scope rule, refusal marker, events
log path, and default merge method are unchanged; the HQ tool's
`config.json` maps 1:1 to the `library.upMerge` stanza above (`repos`,
`registryPath`, `eventsLog`, `mergeMethod`, `branchPrefix`,
`deleteBranchOnMerge`). Repoint the schedule at the new command and
retire the HQ tool.

Ingest authenticates by subkey alone (spec §3.4, main ruling 2026-08-20 option b);
no install-key header is sent. The former `REMOTE_LIBRARY_INSTALL_KEY` env
interim was removed in the same pass.

## Exit codes

`0` ok · `1` usage / configuration error · `2` operational failure · `3` transport/IO failure.

## Testing

```sh
pnpm --dir packages/library-cli test
```

All tests are `node:test` and hit a locally-bound mock ingest server (no
network, no Mongo). Integration against the real docker-compose stack lands
with item 16.
