01 · CORPUS HUB

Corpus Hub: ask a pinned corpus

The Corpus Hub is a catalogue of reference corpora (other projects' source code, standards, advisory feeds), each pinned to one commit and licence-reviewed by a person. CI builds every corpus into a pack: an index that is already built, signed with the hub key. You ask a question with one command. XERJ downloads the pack the first time, checks its sha256 and its ed25519 signature before it extracts anything, restores it into a local node, and answers. Your machine never clones or indexes the corpus.

$ xerj ask --def LZ4_VERSION_RELEASE
lz4  pin 2026-10-02 (15 days old)  mixed: BSD-2 (lib) / GPL-2.0+ (rest)  lz4/lib/lz4.h:133
#define LZ4_VERSION_RELEASE  0    /* for tweaks, bug-fixes, or development */
value: 0
  approach-only licence (mixed: BSD-2 (lib) / GPL-2.0+ (rest)): read, do not copy
answered from: local pack
warning: approach-only licence (mixed: BSD-2 (lib) / GPL-2.0+ (rest)): read, do not copy

Every answer names the corpus, the pinned revision and its age, the licence, and the file:line it rests on. Retrieval is lexical (BM25 over the words and identifiers in the source), not semantic: ask with the names the source uses.

Which binary this page describes. This page applies to the released xerj v1.0.0-rc.97 and later. That release has xerj ask, xerj hub and the built-in hub key, and on 2026-10-21 its xerj hub sync fetched and verified the production signed catalogue (324 corpora). The earlier v1.0.0-rc.96 release has xerj ask and xerj hub but no hub key: there, every pull exits 5 and downloads nothing. To check your binary, run xerj hub --help | grep -c build-catalogue; it prints 1 on a binary this page applies to and 0 on v1.0.0-rc.96. A binary that prints 1 but has no key still exits 5 on the first pull, with the message under When it does not work. The example outputs were captured from main at b3753ddb against a rehearsal hub (a catalogue of lz4, snappy and zstd signed with a test key), before the production catalogue was served. Pack sizes, ages and process IDs on your machine will differ; the formats will not.

Source · engine/crates/xerj-hub/src/ (cli.rs, ask.rs, sync.rs, node.rs) · spec docs/CORPUS_HUB_V2_IMPLEMENTATION.md · browse: hub.xerj.org

Hub, local, or neither

Pick the source before you search:

HUB      public material you cannot quote exactly: a library's source at a
         pinned commit, a standard, a regulation, an advisory feed.
         xerj ask "<question>"

LOCAL    files only this machine has: your project, private documents, exports.
         xerj autoindex <folder> --prefix <name>   (once)
         xerj search "<text>" --prefix <name>     (pass the same --prefix)
         Without --prefix, search and def read the default `ax` prefix.

NEITHER  a fact you can already state exactly. Retrieval adds cost there.

What this build does, and what it does not

Ask your first question

You need the xerj binary and network access to hub.xerj.org and to GitHub release downloads. You do not need to start a node: if nothing answers on localhost:9200, XERJ starts one of its own (see Which node holds the packs).

1. Fetch the catalogue.

$ xerj hub sync
hub sync: catalogue refreshed (generated 2026-10-17T23:35:36Z, 3 corpora)
hub sync: no cached packs to update
hub sync: 0 updated, 0 current, 0 kept, 0 failed, 0 pruned

Verify: echo $? prints 0, and xerj hub status ends with catalogue: 0 days old. xerj ask also fetches the catalogue when none is cached, so this step is optional in a shell. It is required before the MCP tools can answer.

2. Ask for a definition. --def is for a value, a signature or a default. It prints the declaration and, for a constant or a macro, a value: line, and no passages. The first answer from a corpus pulls its pack:

$ xerj ask --def ZSTD_CLEVEL_DEFAULT
xerj hub: pulling 'zstd' (22.1 MB) — one-time download, later questions answer locally
xerj hub: pulled zstd 01b7154f 23137348 → 5 indices, 7874 definitions
zstd  pin 2026-10-02 (15 days old)  mixed: BSD-3 (lib) / GPL-2.0 (cli)  zstd/lib/zstd.h:134
#  define ZSTD_CLEVEL_DEFAULT 3
value: 3
  approach-only licence (mixed: BSD-3 (lib) / GPL-2.0 (cli)): read, do not copy
answered from: local pack
warning: approach-only licence (mixed: BSD-3 (lib) / GPL-2.0 (cli)): read, do not copy

Verify: the output contains answered from: local pack and echo $? prints 0. The two xerj hub: lines go to stderr and appear only on the run that downloads. xerj ask "what is the value of ZSTD_CLEVEL_DEFAULT" and xerj ask ZSTD_CLEVEL_DEFAULT (a lone identifier) ask the same question.

3. Ask how something works. Name the corpus with --corpus when the question is in plain words (see How a corpus is chosen):

$ xerj ask --corpus lz4 --max-tokens 120 "frame decoder"
lz4  pin 2026-10-02 (15 days old)  mixed: BSD-2 (lib) / GPL-2.0+ (rest)  lz4/lib/lz4frame.c:1644
size_t LZ4F_decompress(LZ4F_dctx* dctx,
                       void* dstBuffer, size_t* dstSizePtr,
                       const void* srcBuffer, size_t* srcSizePtr,
                   …
  approach-only licence (mixed: BSD-2 (lib) / GPL-2.0+ (rest)): read, do not copy
answered from: local pack
warning: approach-only licence (mixed: BSD-2 (lib) / GPL-2.0+ (rest)): read, do not copy

Verify: the first line starts with lz4 pin and ends with a file:line. Without --max-tokens you get up to five passages. --files lists the matching files instead of passages.

4. See what is on disk.

$ xerj hub status
pack: lz4 0774d055  15 days old  8.1 MB  last used today
pack: zstd 01b7154f  15 days old  26.5 MB  last used today
node: running  pid 2789576  port 9300
hosted: auto
last sync: today
catalogue: 0 days old

Verify: one pack: line for each corpus you asked.

5. Stop the node XERJ started when you are done:

$ xerj hub stop
stopped the hub node (pid 2789576)

Verify: a second xerj hub stop prints no hub node was started by xerj; nothing to stop. It stops only the process xerj ask recorded. Never pkill xerj or killall xerj: other XERJ nodes may be running on the machine. The packs stay on disk; the next xerj ask starts the node again and answers without a download.

Reading an answer

<corpus>  pin <date> (<N> days old)  <licence>  <file>:<line>    one header per hit
<the declaration, or the passage>
value: <literal>                                         def mode, constants and macros
  <per-hit licence warning>
answered from: local pack
warning: <…>                                             zero or more

pin is the source revision the pack was built from. Check its age when the question is about the current version of a project. Tell the reader which corpus, pin date and licence the answer rests on. warning: approach-only licence means read the source and write your own code; do not copy it.

--json prints the same answer as one object:

$ xerj ask --json --def LZ4_VERSION_RELEASE
{"corpus":"lz4","pin_sha":"0774d05537f9762f838f7ab541b7765f1a729cb5","pin_date":"2026-10-02",
 "age_days":15,"licence_spdx":"mixed: BSD-2 (lib) / GPL-2.0+ (rest)","licence_class":"approach-only",
 "answered_from":"local_pack","mode":"def","hits":[{"file":"lz4/lib/lz4.h","line":133,"kind":"const",
 "text":"#define LZ4_VERSION_RELEASE  0    /* for tweaks, bug-fixes, or development */","value":"0",
 "score":14.069364,"licence_warning":"approach-only licence (mixed: BSD-2 (lib) / GPL-2.0+ (rest)): read, do not copy"}],
 "truncated":false,"warnings":["approach-only licence (mixed: BSD-2 (lib) / GPL-2.0+ (rest)): read, do not copy"]}

(Wrapped here for width; the binary prints one line.) Verify: xerj ask --json --def LZ4_VERSION_RELEASE | jq -r .hits[0].value prints 0.

How a corpus is chosen

--corpus <slug> skips routing. Without it, XERJ scores each corpus in the cached catalogue from the dependencies of the project you are in (Cargo.lock, package.json, go.mod, requirements.txt, pyproject.toml), from the catalogue's per-corpus term sketch, and from the exact symbol a definition question names. Three outcomes:

# one corpus fits: it answers (exit 0)

# more than one fits: the candidates print, one per line (exit 4)
$ xerj ask XXH64
more than one corpus matches; re-run with --corpus <slug>
lz4 — an agent needing fast block compression queries this for the block format and decoder loop
zstd — an agent tuning compression queries this for frame/block formats, window and level internals

# nothing scores high enough (exit 1)
$ xerj "how does the lz4 frame decoder work"
no hub corpus matches; see xerj.org/llms.txt
closest corpora: lz4, snappy, zstd; re-run with --corpus <slug> to ask one of them

A question in plain words often does not route by itself, even when it contains the project's name, as the last example shows. When you see closest corpora:, run the question again with --corpus. When there is no closest corpora: line, the hub does not hold the material: say so, and do not answer from memory.

xerj "<question>" is the same command as xerj ask "<question>" when the question contains a space and its first word is not a subcommand. For a single word, write xerj ask <word>.

Which node holds the packs

A pack is restored into a running XERJ node. xerj ask uses the first of --url, XERJ_URL and http://localhost:9200. If that node answers, it is used as it is. If it does not answer and is a loopback address, XERJ starts its own node on the first free port from 9300, with its data in ~/.xerj/hub/node, and records its PID:

xerj hub: no node answers at http://127.0.0.1:29599; starting a local node on port 9300 (data: /home/you/.xerj/hub/node, log: /home/you/.xerj/hub/node.log)
xerj hub: node ready on port 9300 (pid 2789576)

A non-loopback --url that does not answer is an error; no local node is started in its place.

Using a node you started yourself. The engine restores a snapshot only from its own data directory or from a directory named in [limits] snapshot_repo_allowlist. Packs are extracted under ~/.xerj/hub/packs, so a node started without that setting refuses the restore:

$ xerj hub pull lz4
xerj hub pull: restore snapshot: POST /_snapshot/hub-lz4/lz4-0774d055/_restore?wait_for_completion=true -> HTTP 400: {"error":{…"reason":"snapshot repository location [/home/you/.xerj/hub/packs/lz4/0774d055/snapshot] is outside data_dir and not in limits.snapshot_repo_allowlist; refusing (set the allowlist to permit external snapshot roots)"…}

Fix: add the pack directory to the node's config file, as an absolute path (TOML does not expand ~), and restart the node with --config:

$ printf '[limits]\nsnapshot_repo_allowlist = ["%s/.xerj/hub/packs"]\n' "$HOME" > ~/xerj-hub.toml
$ nohup xerj --insecure --data-dir ~/xerj-data --config ~/xerj-hub.toml > ~/xerj-node.log 2>&1 & echo $! > ~/xerj-node.pid
$ xerj hub pull lz4
pulled lz4 0774d055 5810327 → 2 indices, 1702 definitions

Verify: the last line starts with pulled lz4 (or lz4: already present on a second run). Stop that node with kill $(cat ~/xerj-node.pid). For a node with auth on, export XERJ_API_KEY (the key is in <data-dir>/admin.key).

xerj code does not start a node. xerj code <slug> --def <SYMBOL> and xerj code <slug> "<query>" read a pulled pack too, but only from --url, XERJ_URL or localhost:9200. If the pack lives in the node xerj ask started, pass its port (from xerj hub status), or use xerj ask --corpus <slug>:

$ xerj code lz4 --def LZ4_MAX_INPUT_SIZE --url http://127.0.0.1:9300
lz4  pin 2026-10-02 (15 days old)  mixed: BSD-2 (lib) / GPL-2.0+ (rest)  lz4/lib/lz4.h:214
#define LZ4_MAX_INPUT_SIZE        0x7E000000   /* 2 113 929 216 bytes */
value: 0x7E000000
  approach-only licence (mixed: BSD-2 (lib) / GPL-2.0+ (rest)): read, do not copy
answered from: local pack

Verify: exit 0 and a value: line. Pointed at a node without the pack, the same command prints defs query failed: … and no match in lz4, and exits 1.

Offline, no download, and the size cap

$ xerj ask --offline --corpus snappy "varint decoding"
no local pack for 'snappy' and --offline forbids downloading it; run: xerj hub pull snappy (14.3 MB)

$ xerj ask --no-fetch --corpus zstd --def ZSTD_CLEVEL_DEFAULT
no local pack for 'zstd' and --no-fetch forbids downloading it; run: xerj hub pull zstd (22.1 MB)

Both exit 1. --offline never touches the network: it reads the cached catalogue and cached packs only, and a corpus already on disk answers normally. --no-fetch does not download a pack. Run the printed line, then ask again:

$ xerj hub pull snappy
pulled snappy 9c28114a 15034490 → 3 indices, 382 definitions

Verify: pulled <slug> <pin8> <bytes> → <n> indices, <defs> definitions and exit 0.

For a pack over the 50 MiB auto-pull cap, xerj ask prints '<slug>' is <MB> MB, over the 50.0 MB auto-pull cap, so a question does not download it automatically and no hosted answer is available; run `xerj hub pull <slug>` to fetch it once, then ask again and exits 1. The explicit xerj hub pull <slug> does fetch it, bounded by the 4 GiB disk budget (DISK_BUDGET_BYTES), so the corpus is usable once pulled; only a pack larger than the whole disk budget is refused.

The hosted setting

--hosted on|off|auto on xerj ask, or xerj hub config hosted on|off|auto stored in ~/.xerj/hub/config.toml, decides whether a question may be sent to a hosted hub node. auto, the default, is off inside a git repository (the first time, XERJ prints hosted answers are off in git repositories; xerj hub config hosted on) and on elsewhere. No public hosted node exists, and this build has no hosted client, so every answer comes from a local pack whatever the setting.

$ xerj hub config hosted off
hosted = off

Verify: xerj hub status shows hosted: off.

From an MCP client

xerj mcp serves three hub tools next to its other tools. They run the same flow as xerj ask, through the node that answers on --url / XERJ_URL or else the node XERJ starts:

xerj_hub_ask   {question, corpus?, mode?: "def"|"how"|"files", max_tokens?}   one turn, like xerj ask
xerj_hub_def   {slug, symbol, k?}                                             like xerj ask --corpus <slug> --def <SYMBOL>
xerj_hub_find  {question, k?}                                                 which corpora could answer; no passages

A hub tool never downloads a pack and never fetches the catalogue. A tool call must not wait on the network. Run xerj hub sync in a shell once, and xerj hub pull <slug> for each corpus, then call the tool. Until then the tool returns an error (isError: true) that says which command to run:

no hub catalogue is cached on this machine, and hub tools never fetch one (a tool call does not wait on the network); run `xerj hub sync` in a shell once, then call the tool again (…)

no local pack for 'lz4' and --no-fetch forbids downloading it; run: xerj hub pull lz4 (5.5 MB)

With the pack on disk, xerj_hub_def {"slug":"lz4","symbol":"LZ4_VERSION_RELEASE"} returns the same text as the shell answer, and the structured content is the answer JSON. xerj_hub_find {"question":"XXH64"} returns:

lz4 — an agent needing fast block compression queries this for the block format and decoder loop  (score 0.45, pin 2026-10-02, mixed: BSD-2 (lib) / GPL-2.0+ (rest), cached)
zstd — an agent tuning compression queries this for frame/block formats, window and level internals  (score 0.45, pin 2026-10-02, mixed: BSD-3 (lib) / GPL-2.0 (cli), cached)
routing: ambiguous; ask one with xerj_hub_ask {question, corpus}

Register it once, with the absolute path of the binary (an editor started from a desktop icon does not inherit your shell's PATH; command -v xerj prints it), then restart the session:

# Claude Code
claude mcp add --scope user xerj -- "$(command -v xerj)" mcp

# .mcp.json (Claude Code project scope; `xerj init` writes it), Cursor, Windsurf,
# Claude Desktop, Cline: root key mcpServers
{"mcpServers": {"xerj": {"command": "/abs/path/to/xerj", "args": ["mcp"]}}}

# VS Code, .vscode/mcp.json: root key servers (a block under mcpServers is ignored)
{"servers": {"xerj": {"type": "stdio", "command": "/abs/path/to/xerj", "args": ["mcp"]}}}

Verify without a client: this prints xerj_hub_ask once.

$ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \
    '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | xerj mcp 2>/dev/null | grep -o '"name":"xerj_hub_ask"'
"name":"xerj_hub_ask"

A build with no hub key serves 13 tools and omits the three hub tools. The full schemas are in mcp-tools.json; the endpoint contract lists them with the other agent operations.

Keeping packs current

$ xerj hub sync
hub sync: catalogue refreshed (generated 2026-10-17T23:35:36Z, 3 corpora)
hub sync: lz4 0774d055: current
hub sync: 0 updated, 1 current, 0 kept, 0 failed, 0 pruned

xerj hub sync refreshes the catalogue, pulls the new pin of every cached pack whose pin moved (sha256 and signature are checked first), removes the old pin only after the new one is restored, prunes to the disk budget, and writes ~/.xerj/hub/last_sync. A failed update keeps the old pin serving. Verify: exit 0 and 0 failed on the last line.

$ xerj hub sync --install-timer
/home/you/.config/systemd/user/xerj-hub-sync.timer

This writes a systemd user timer and service (Linux) or a launchd agent (macOS) that runs xerj hub sync every 6 hours, and prints the path. It enables, loads and starts nothing: the comment at the top of the written file says how. Verify: the printed file exists. Hub packs are never refused as stale; the age is in every answer's header, and a newer pin arrives with sync.

Exit codes

xerj ask         0 answered · 1 no match, nothing to answer from, or a pull/node failure
                 2 usage error or unknown corpus · 3 a pack failed verification
                 4 more than one corpus matches (candidates printed; re-run with --corpus)
                 5 this build has no hub public key: nothing was downloaded
xerj hub pull    0 pulled or already present · 1 anything else (excluded licence and
                 over-the-cap packs included) · 2 unknown corpus · 3 verification failed
                 5 no hub public key
xerj hub sync    0 synced or nothing to do · 1 catalogue not refreshed or a pack not
                 updated (old pin kept) · 2 usage error · 3 a pack failed verification

Exit 3 means the bytes did not match: nothing was extracted, and a retry downloads the same bytes. Report it rather than retrying. Exit 5 cannot be fixed by a retry either.

Where things live

~/.xerj/hub/catalogue.json, .sig   the cached, signed catalogue (refreshed after 24 h, or by sync)
~/.xerj/hub/packs/<slug>/<pin8>/  extracted packs (pack.json, SHA256SUMS, snapshot/, last_used)
~/.xerj/hub/node/                  data of the node xerj ask starts; node.log, node.pid,
                                   node.port and node.toml beside it
~/.xerj/hub/config.toml            the hosted setting
~/.xerj/hub/last_sync              written by a sync with no failures

XERJ_HUB_HOME moves the whole directory. XERJ_HUB_URL points the client at another hub (default https://hub.xerj.org; the catalogue is read from <hub>/api/v1/catalogue.json and its .sig). Both are listed on the environment page.

When it does not work

The catalogue is unavailable. A static host can answer an unknown path with an HTML page and status 200. The client checks the content and the signature, and refuses it:

$ xerj hub sync
xerj hub sync: cannot refresh the catalogue; nothing was changed: the hub catalogue is unavailable: https://hub.xerj.org/api/v1/catalogue.json returned 264903 bytes starting "<!doctype html><html lang=\"en\"><head><me" (text/html; charset=utf-8), not a signed catalogue; no catalogue is served at this address

Exit 1, and a cached catalogue (if any) is kept. xerj ask and xerj hub pull print the same reason after cannot load the hub catalogue:. Packs already on disk still answer with --offline.

A pack or the catalogue fails verification.

$ xerj hub pull snappy
xerj hub pull: pack verification failed: pack sha256 mismatch: archive is 97351ed6…, expected ae10dc7d…
$ xerj hub sync
xerj hub sync: cannot refresh the catalogue; nothing was changed: catalogue signature did not verify; refusing to replace the cache: …

The pull exits 3 and extracts nothing; the sync exits 1 and keeps the previous catalogue. (Both were produced by changing one byte of a served file.)

No hub key in this build.

$ xerj ask --corpus snappy "varint"
pull of 'snappy' failed: no hub public key is configured in this build (or XERJ_HUB_PUBKEY=none), so a pack's signature cannot be verified; nothing was downloaded

Exit 5. Use a binary that has the key (see the check at the top of this page). For your own files, xerj autoindex works in every build.

Contributing a corpus

The registry lives on the corpus-hub branch of xerj-org/xerj, and contributions arrive as pull requests against that branch. Its CONTRIBUTING.md is the source of record for the rules; this section is the walkthrough. Browse the registry at hub.xerj.org.

You contribute a build input, never a pack. A manifest or a recipe goes into git. CI (.github/workflows/hub-packs.yml) builds the pack with xerj hub build-pack, signs it with the hub key, pulls it back on a fresh node and checks one --def and one question before it publishes the release, then rebuilds and signs the catalogue with xerj hub build-catalogue. Users download the signed pack; they do not rebuild the corpus from your manifest.

Two lanes

LANE A — reference corpus   tools/xerj-code/hub/<name>.json   (a manifest)
LANE B — record pack        tools/packs/<name>/recipe.toml     (a recipe)

A reference corpus is other people's source code at pinned commits, the material for reference coding ("how do three other engines implement merge policy?"). A record pack is structured data you curate and resolve, for example Rust vulnerability advisories identity-merged across osv.dev and RustSec. If your content is "files someone can clone", it is Lane A; if it is "records distilled from one or more sources", it is Lane B. The catalogue that xerj ask reads lists Lane A corpora; the record packs are published separately by pack-publish.yml (below).

Lane A — the manifest

Copy tools/xerj-code/hub/TEMPLATE.json to tools/xerj-code/hub/<your-corpus>.json. The corpus name must equal the filename. Field by field:

{
  "corpus": "xerj-storage",
  "cloned_at": "<UTC timestamp the build ran>",
  "repos": [
    {
      "repo": "sled",
      "url": "https://github.com/spacejam/sled",
      "licence": "<what the detector read — do not trust this alone>",
      "sha": "<full 40-hex commit the build actually used>",
      "files": 417, "bytes": 2900000,
      "review": {
        "spdx": "<SPDX id YOU confirmed by opening the licence file>",
        "use": "adapt-with-attribution",
        "by": "your-handle", "at": "2026-10-01",
        "note": "<one line: what you opened, what you concluded>"
      }
    }
  ]
}

The two fields CI cannot check, and a reviewer will hold you to: sha must be the commit the build actually used (never a branch head), and review must be a human's verdict (see the licence contract). The sha becomes the pack's pin, the pin every answer prints.

Lane B — the recipe

Copy tools/packs/TEMPLATE-recipe.toml to tools/packs/<name>/recipe.toml. Everything domain-specific lives in the recipe, never in the tooling:

[recipe]        format = 1 · name = directory name · description
[[sources]]     one per source: slug, kind (git|http-zip|dir), url,
                glob, format (flat|osv|rustsec-md), licence
[envelope]      how a raw source file becomes one searchable record:
                id_from, title_from, body_join, defs_from
[identity]      which fields make two records the same thing
                (edges, each=true explodes list fields; union-find
                closes the chain) + canonical_source_order
[merge]         precedence when two sources carry the same fact
[[derived]]     optional build-time fields (regex_extract, present)
[emit]          shards

The identity block is the whole dedup contract: write it so a reader can predict the merged count, then put the measured count (envelopes → records) in your README. The showcase to read alongside the template is tools/packs/rust-vulns/recipe.toml. Built packs are never committed; a maintainer builds from your recipe and attaches the signed pack to a GitHub Release.

The licence contract

Every source carries a human review block with one of three verdicts:

adapt-with-attribution   Apache-2.0 · MIT · BSD — cite file:line when
                         you adapt; copying is fine within the licence
approach-only            AGPL · SSPL · Elastic · BUSL · GPL — read the
                         design, write your own code, never paste
mixed                    per-file split (e.g. MIT core + BUSL edition) —
                         name the boundary in the note

The verdict is what xerj ask prints: an approach-only or mixed corpus carries read, do not copy on every hit. Our licence detector once reported Elasticsearch as Apache-2.0, because the AGPL text contains the phrase «an "Apache License 2.0" compatible license». A detector's false "permissive" is how restricted code ends up pasted into an Apache-2.0 project. CI checks that a review block exists and that its fields are well-formed; only the reviewer's own reading of the licence file makes it true.

The pull request

# 1. branch FROM corpus-hub (not main) so the CI workflow is in your PR
  git clone -b corpus-hub https://github.com/xerj-org/xerj && cd xerj

# 2. add your manifest or recipe, then validate locally (stdlib only)
  python3 tools/xerj-code/hub/validate_hub.py

# 3. commit, push, open the PR against corpus-hub
  git checkout -b corpus/<your-name>
  git add tools/xerj-code/hub/<your-name>.json
  git commit -m "corpus: add <your-name> (<domain>)"
  git push -u origin corpus/<your-name>

CI on every push and PR runs the same validator plus two registry guards: no file over 5 MB under the registry paths (built packs and key material must not be in git), and no private key material under tools/packs/keys. A maintainer then reviews domain fit, pin truthfulness and, above all, the licence verdicts, and merges. After the merge, CI builds the pack; a pack whose consumer check fails stays a draft and is not catalogued.

Is your corpus a fit?

1. Can you name the domain? Finish the sentence: "an agent working on ___ would query this for ___." That sentence becomes the corpus's ask_for line, which xerj ask prints when it lists candidates. A corpus that contains everything retrieves like a search engine with no query.

2. Is the content un-memorised? Retrieval helps most on niche, private, internal and post-cutoff material. On famous public library code a model already knows, retrieval is overhead. Do not force it where it loses.

Your own corpora. For a set of repositories only you need, xerj corpus add <name> <git-url>… (or --from a manifest file you wrote) and xerj corpus index <name> still clone and index on your machine, and xerj code <name> searches the result. That is a local path and does not use the hub.