@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
442 lines (347 loc) • 20.9 kB
Markdown
# Develop KXM
Set up a source checkout, learn where each part of KXM lives, and run the same
gate CI runs before you push. This page is for contributors who change the CLI,
the [hub](../glossary.md#hub), the [Runtime](../glossary.md#runtime), the Pi
extension, the Claude Code plugin, or the bundled skills.
## Before you begin
- Node.js 22.19 or newer on the 22 line, or Node.js 24 or newer. The
`engines` field in `package.json` is the authority.
- npm and Git.
- Optional tools, needed only for the matching task:
- [Pi](https://pi.dev) to load the extension from source.
- The Claude Code CLI to validate the plugin manifests locally.
- [`just`](https://github.com/casey/just) for the remaining
[assignment runner](assignment-runner.md) recipes only:
`assign`, `witness`, `accept`, `observe-cost`, `attribute`,
`change-report`, and `plan-current`. Those recipes are harness
transport for the issue 127 runner. They are not how a unit is
dispatched. Do not rename or delete them.
- `kxm lane run` to dispatch a unit. A writer unit uses
`kxm lane run <unit> --brief <file> --workflow implement-only`.
The read-only critic workflows on the same command are
`review-arch-only` and `review-cli-only`.
- Docker for the clean-container install smoke and the Python control-plane
contract suite.
- Python 3.13 and [`uv`](https://docs.astral.sh/uv/) for the Python
control-plane contract suite.
## Set up a checkout
```bash
git clone https://github.com/kontextmind/kxm.git
cd kxm
npm ci
node scripts/kxm.mjs --help
```
`node scripts/kxm.mjs` is the `kxm` binary from `package.json` `bin`. It runs the
committed bundle `plugins/kxm/dist/cli.js`, not the TypeScript source. A source
change is invisible until you rebuild:
```bash
npm run build
```
Use `node scripts/kxm.mjs` wherever the user docs show `kxm`.
### Keep experiments away from real state
A source build uses the same default hub (`http://127.0.0.1:7331`), user state
root, configuration and telemetry directories as an installed `kxm`. Point every
experiment at throwaway directories and a hub you start yourself on another
port:
```bash
# Run in a scratch Git repository, not in your KXM checkout.
export KXM_STATE_HOME="$(mktemp -d)"
export KXM_USER_CONFIG_DIR="$(mktemp -d)"
export KXM_USER_TELEMETRY_DIR="$(mktemp -d)"
export KXM_PORT=47331
export KXM_SERVER_URL="http://127.0.0.1:$KXM_PORT"
node /path/to/kxm/scripts/kxm.mjs hub start
```
In a second terminal with the same variables:
```bash
node /path/to/kxm/scripts/kxm.mjs hub view
node /path/to/kxm/scripts/kxm.mjs hub stop
```
Expected output of `hub view`:
```text
hub health=true ready=true · loopback hub
```
`KXM_STATE_HOME` must be an absolute path. The hub writes its database and logs
under `.kxm/` in the directory where you start it.
### Load the Pi extension from source
Pi loads the TypeScript extension directly, so it needs no build step:
```bash
pi --no-extensions -e ./plugins/kxm/src/extension.ts
```
`--no-extensions` turns off Pi's package discovery, so only the listed extensions
load. Add another `-e` for each provider extension your models need.
### Validate the Claude Code plugin
With the Claude Code CLI installed, run the same strict manifest validation as
CI. Validation reads the manifests only; the plugin itself runs the committed
`plugins/kxm/dist/mcp-server.js`, so rebuild before you try it in Claude Code.
```bash
npm run validate:claude
```
## Repository layout
The tree below lists what is tracked in Git. Directories marked "not shipped" are
excluded from the npm package.
```text
.
├── plugins/kxm/ The product: CLI, hub, Runtime, Pi extension, MCP server
│ ├── src/ TypeScript source (cli/, context/, providers/ inside)
│ ├── dist/ Generated, committed bundles (see Generated artifacts)
│ ├── skills/ Bundled Agent Skills, one <name>/SKILL.md each
│ ├── skill-suite.json Skill manifest: names, paths, owned commands
│ ├── .claude-plugin/ Claude Code plugin manifest (plugin.json)
│ └── .mcp.json MCP server launch for the plugin
├── .claude-plugin/ Claude marketplace catalog (marketplace.json)
├── packages/core/tui/ @kontextmind/tui workspace package
├── scripts/ kxm.mjs, hub and worker wrappers, build, release, runners
├── schemas/ JSON Schemas for YAML resources, events and records
├── docs/ User, operator and contributor documentation
├── examples/ Runnable transport examples and project fixtures
├── test/ core/, simulations/, fixtures/, helpers/ (not shipped)
├── .kxm/ This repository's own KXM project definition
├── .agents/skills/ Generated mirror of the bundled skills (not shipped)
├── .github/ CI, release, issue and PR templates (not shipped)
├── .claude/ Developer harness notes and commands (not shipped)
├── plans/ Internal planning and tracking (not shipped)
├── justfile Remaining issue 127 recipes (assign, witness, accept, observe-cost, attribute, change-report, plan-current)
└── AGENTS.md, CLAUDE.md, GEMINI.md Agent instructions with generated blocks
```
The workspace layout for `packages/` is described in
[Packages and workspaces](packages.md). The `.kxm/` layout is described in
[`.kxm/README.md`](../../.kxm/README.md).
## Packaging standards
KXM ships one product through four native package formats. Each format reads
its own manifest, and `npm run check:versions` keeps every version field equal.
### npm package
`@kontextmind/kxm` is published publicly.
| Field | What it declares |
|---|---|
| `bin` | `kxm` → `scripts/kxm.mjs` |
| `exports` | `./core`, `./runtime`, `./client`, `./extension`, `./mcp` from `plugins/kxm/dist`, and `./tui` |
| `files` | `plugins/kxm/{dist,src,skills}`, `scripts`, `docs`, `examples`, `schemas`, the plugin manifests, and selected `.kxm` files |
| `engines` | Node `^22.19.0 \|\| >=24.0.0` |
| `peerDependencies` | Pi and `typebox`, both optional |
Everything under `docs/` ships in the tarball, so a link from `docs/` into
`plans/` is dead for npm readers. `.npmignore` drops logs, state and generated
assets even inside listed directories. Inspect the result with
`npm pack --dry-run`.
### Claude Code plugin and marketplace
| File | Role |
|---|---|
| `.claude-plugin/marketplace.json` | Marketplace catalog; its `kxm` entry points at `./plugins/kxm` |
| `plugins/kxm/.claude-plugin/plugin.json` | Plugin manifest: `userConfig`, the `SessionStart` hook, the channel, and `mcpServers` |
| `plugins/kxm/.mcp.json` | Launches `dist/mcp-server.js` over stdio and maps `userConfig` to `KXM_*` variables |
| `plugins/kxm/dist/claude-hook.js` | The bundled, read-only `SessionStart` hook script |
A marketplace install clones the repository, so the plugin can use only committed
files. That is why the bundles in `dist/` are tracked. For what the plugin does,
see the [plugin README](../../plugins/kxm/README.md).
### Pi package
The root `package.json` carries the `pi-package` keyword and a `pi` block:
`pi.extensions` lists the tracked `./plugins/kxm/dist/extension.js` bundle and
`pi.skills` lists `./plugins/kxm/skills`. Development can still load
`plugins/kxm/src/extension.ts` explicitly; published installs use the bundle so
private workspace packages are never unresolved runtime dependencies.
### Agent Skills
Each bundled skill is a directory `plugins/kxm/skills/<name>/` with a `SKILL.md`.
`plugins/kxm/skill-suite.json` declares every skill and the top-level `kxm`
commands it owns. `test/core/skill-suite.test.ts` enforces the rules:
- The directory name equals the frontmatter `name` and the manifest `name`.
- The frontmatter parses as strict YAML. Quote any value that contains a colon followed by a space.
- The `description` is at most 1,024 characters.
- Every top-level `kxm` command is owned by exactly one skill.
- `.agents/skills/` matches the authored skills byte for byte.
For what each skill covers, see [Agent skills](../guides/agent-skills.md).
## The development loop
1. Branch from `main`. Keep one concern per branch and one clear concern per
commit.
2. Change the source and the test that proves the change. Update
[the test matrix](test-matrix.md) when you add a behavior.
3. Rebuild with `npm run build` when you touched anything that is bundled.
4. Run the focused test file while you iterate (see
[Test conventions](#test-conventions)).
5. Update the user docs and `CHANGELOG.md` (under `## Unreleased`) for any
user-visible change.
6. Stage the source change together with the regenerated files. The commit gate
compares generated files with the staged copy.
7. Run the commit gate, `npm run verify`, fix what it reports, then commit.
8. Push and open a pull request. The template asks for the slice issue and the
`npm run verify` result.
Maintainers who delegate a unit dispatch it with
`kxm lane run <unit> --brief <file> --workflow implement-only`.
Read-only critics use the same command with `review-arch-only` or
`review-cli-only`. The [assignment runner](assignment-runner.md)
recipes (`assign`, `witness`, `accept`, `observe-cost`, `attribute`,
`change-report`, `plan-current`) stay on `just`. They are harness
transport for the issue 127 runner, not the unit transport, and they
cover steps 2 to 7 after a lane run.
## Generated artifacts
`npm run build` regenerates the files below. They are committed because the
Claude marketplace installs from Git and npm consumers install without dev
dependencies. Neither can run a build or strip TypeScript at install time.
| Artifact | Produced by | Consumers |
|---|---|---|
| `plugins/kxm/dist/cli.js`, `server.js`, `runtime-supervisor.js`, `claude-hook.js` | `scripts/build-runtime.mjs` | `kxm`, the hub, the Runtime supervisor, the plugin hook |
| `plugins/kxm/dist/core.js`, `runtime.js`, `client.js`, `extension.js` | `scripts/build-runtime.mjs` | The package `exports` |
| `plugins/kxm/dist/mcp-server.js` | `npm run build:mcp` | The Claude plugin and `@kontextmind/kxm/mcp` |
| `packages/core/tui/dist/index.js` | `npm run build:packages` | `@kontextmind/kxm/tui` |
| `.agents/skills/<name>/…` | `scripts/emit-codex-artifacts.mjs` | Harnesses that read `.agents/skills` |
| `AGENTS.md` block between the `kxm:codex:commands` markers | `scripts/emit-codex-artifacts.mjs` | Codex and other `AGENTS.md` readers |
| `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` blocks between the `kxm:memory` markers | `syncHarnessMemory` (same as `kxm memory sync`) | Every harness, from `.kxm/memory/*.md` |
Never edit a generated file by hand. Edit its source and rebuild.
`npm run check:generated` rebuilds, then fails if any artifact is missing, not
tracked, or different from the staged copy. Stage the rebuilt files
(`git add plugins/kxm/dist packages/core/tui/dist .agents AGENTS.md CLAUDE.md GEMINI.md`)
before you run it.
## The commit gate
Run `npm run verify` before every commit you push. It is the same gate for
people and for agents.
```bash
npm run verify
```
It runs three scripts in order:
| Step | Script | What it checks |
|---|---|---|
| 1 | `npm test` | Build, then `test/core/*.test.ts` and `packages/core/*/tests/unit/*.test.ts`. The script passes `--test-force-exit` so a finished run leaves the process even if a handle is still open; a failing test is still reported before that exit |
| 2 | `npm run check` | `tsc --noEmit`, markdownlint, the `plans/` relative-link check, and version surfaces |
| 3 | `npm run check:generated` | Generated artifacts are tracked and match the staged copy |
Changes to the Python control plane also run its frozen-workspace contract
suite before the npm gate. The suite drops and recreates `kxm_mg1_test`, so
the admin DSN account needs database create and drop privilege. The `postgres`
superuser in the container below is that account. Run PostgreSQL 17 in a
disposable container published only on loopback `127.0.0.1:5432`, with
`POSTGRES_PASSWORD=postgres`. Leave port `5432` on loopback free. Wait until
`pg_isready` succeeds, export the two DSNs CI uses, run the frozen suite, and
remove the container afterward:
```bash
docker rm -f kxm-mg1-pg >/dev/null 2>&1 || true
docker run --detach --name kxm-mg1-pg \
--publish 127.0.0.1:5432:5432 \
--env POSTGRES_PASSWORD=postgres \
postgres:17
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
docker exec kxm-mg1-pg pg_isready -U postgres && break
sleep 1
done
docker exec kxm-mg1-pg pg_isready -U postgres
export KXM_MG1_ADMIN_DSN='host=127.0.0.1 port=5432 dbname=postgres user=postgres password=postgres'
export KXM_MG1_TEST_DSN='host=127.0.0.1 port=5432 dbname=kxm_mg1_test user=postgres password=postgres'
uv run --project python --frozen python -m unittest discover -s python/kxm-control/tests
suite_status=$?
docker rm -f kxm-mg1-pg
(exit "$suite_status")
```
The required CI aggregate repeats that suite against PostgreSQL 17 with the
same two DSNs and the same `postgres` superuser. On the `kontextmind-doks`
runners, `scripts/ci-postgres-17.sh` starts that server on `127.0.0.1:5432`
from a pinned PostgreSQL 17 binary. Contributors use the `postgres:17`
container procedure above, which publishes the same port and DSNs. The suite
remains a CI lane rather than a third root gate script.
Every suite script passes `--test-force-exit`, and `scripts/run-bounded.mjs` stops the run if the suite is still going after its wall-clock limit (20 minutes, or 40 for the complete and coverage suites). The bound is that wall clock per script. A per-test timeout is not used because under `node --test` each file is itself a test, so a per-test timeout also bounds each file.
Other scripts you will use:
| Script | Use it for |
|---|---|
| `npm run test:core` | The core suite only (CI runs a smaller contract set; see [CI and release](ci-and-release.md)) |
| `npm run test:simulations` | `test/simulations/*.test.ts` |
| `npm run test:coverage` | Core suite with coverage floors |
| `npm run test:coverage:complete` | Core plus simulations with the higher nightly floors |
| `npm run lint:docs` | Markdown lint, then the `plans/` relative-link and heading check |
| `npm run e2e` | Start Obscura if needed, then Playwright in `test/e2e/` (`node --test` does not collect that directory) |
| `npm run validate:pr` | The three-minute CI gate; see [CI and release](ci-and-release.md) |
| `npm run validate:ci` | Coverage suite, `check` and a package dry run (not run by CI today) |
| `npm run validate:claude` | Strict Claude plugin and marketplace validation |
Plugin validation needs the Claude Code CLI, so it is a CI job rather than part
of `verify`. Do not add a third gate script: `test/core/ci-contract.test.ts`
refuses one.
### Coverage floors
| Run | Lines | Branches | Functions |
|---|---:|---:|---:|
| `test:coverage:core` (pushes to `main`, releases) | 91% | 80% | 92% |
| `test:coverage:complete` (nightly) | 93% | 80% | 93% |
Coverage measures `plugins/kxm/src/**/*.ts` and `packages/core/*/src/**/*.ts`.
The floors only move up. A pull request may raise a floor. No pull request may
lower one: when coverage drops, add tests.
Three files are excluded because they run only as spawned bundles, so coverage
would attribute their lines to `dist`, never to the source. Each is exercised
as a child process instead:
- `plugins/kxm/src/server.ts`, the hub entry;
- `plugins/kxm/src/mcp-server.ts`, the MCP stdio entry;
- `plugins/kxm/src/runtime-supervisor.ts`, the Runtime supervisor entry.
An exclusion needs a reason other than "hard to test".
## Test conventions
Tests use the built-in `node:test` runner and run TypeScript through
`--experimental-strip-types`. There is no Jest or Vitest.
### Write one focused test per behavior
- Name each test after the behavior it proves, as a sentence. For example,
`"MCP server never registers with the persisted admin token"`.
- Assert the observable contract: an exit code, a JSON field, an error code, a
file on disk. Do not assert log wording unless the wording is the contract.
- Prove the refusal as well as the success. Fail-closed paths need their own test.
- Put the test next to its peers in `test/core/<area>.test.ts`, and add a row to
[the test matrix](test-matrix.md).
### Run one file or one test
Build once, then run the file directly:
```bash
npm run build
node --disable-warning=ExperimentalWarning --experimental-strip-types \
--test test/core/improve.test.ts
# Narrow to tests whose names match a pattern.
node --disable-warning=ExperimentalWarning --experimental-strip-types \
--test --test-name-pattern="same-ask" test/core/improve.test.ts
```
Several suites spawn the built bundles, so a stale `dist` fails them. Rebuild
before you trust a failure.
### Isolate all state
A test must never read or write the operator's hub, state root, configuration or
credentials. Use the shared helpers:
| Helper | What it isolates |
|---|---|
| `isolateSessionEnvironment()` in `test/helpers/session-env.ts` | Points `KXM_STATE_HOME` and `KXM_USER_CONFIG_DIR` at a temp directory and clears session tokens |
| `createTestMesh(context)` in `test/helpers.ts` | Starts an in-memory hub on port `0` with a test token, closed when the test ends |
| `committedKxmProject(prefix, …)` in `test/helpers/project.ts` | Creates a committed KXM project and a separate state root in temp directories |
| `test/helpers/harness-fake.ts` | Fake spawns and auth fixtures for `scripts/harness-run.mjs`, so no real harness or login is needed |
Other rules:
- Create files only under `mkdtempSync(join(tmpdir(), …))` and remove them when
the test ends.
- Bind servers to port `0`. Never assume `7331` is free.
- Make no network calls outside loopback, and never call a paid model. Real-model
checks are opt-in smoke tests (see [CI and release](ci-and-release.md#smoke-tests)).
- Tests must pass on Node 22.19 and Node 24.
## Change the protocol
Routes, request fields, status transitions, delivery modes, limits and
authentication are the protocol. A change to any of them needs:
- integration tests;
- an update to `plugins/kxm/skills/kxm/references/protocol.md`;
- an update to the matching page in `docs/`;
- a `CHANGELOG.md` entry;
- a compatibility note when existing clients could break.
Prefer additive changes. KXM does not keep backward-compatible aliases for
retired names: an old name fails closed instead.
## Refresh model prices and inventory
OpenRouter list prices and the model inventory are refreshed by
`.github/workflows/refresh-models.yml` every Monday at 13:00 UTC. To run the
same refresh before that, from the repository root:
```bash
node --disable-warning=ExperimentalWarning --experimental-strip-types scripts/refresh-model-catalog.mjs --dry-run
```
`--dry-run` prints models added, removed, and repriced, and writes nothing.
Drop `--dry-run` to update `.kxm/prices.yaml` and `.kxm/models/inventory.yaml`.
Exit 2 means a model named by `.kxm/routes.yaml` or a roster left the OpenRouter
catalog; the refresh keeps that inventory row and sets `missingUpstream: true`.
Review the diff and commit it yourself, or wait for the Monday pull request on
`automation/model-refresh`. The field reference is
[Weekly OpenRouter refresh](../reference/config-reference.md#weekly-openrouter-refresh).
`kxm models inventory-refresh` still rewrites the whole inventory from Pi, Grok,
agy, OpenRouter, and Nous, including a new `fetchedAt` timestamp. The weekly
script does not call those local CLIs.
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| `generated artifacts changed after build` | `dist`, a skill mirror or a generated block differs from the index | Run `npm run build`, stage the listed files, rerun |
| `generated artifacts are not tracked by git` | A new bundle or skill file was never added | `git add` the listed paths |
| `… version … does not match …` from `check:versions` | A version surface was edited by hand | Revert it; releases stamp versions (see [CI and release](ci-and-release.md#versions)) |
| A doc test fails after a docs move | A test or code path reads that doc | See [pinned paths](writing-docs.md#pinned-paths) |
| A CLI change has no effect | `node scripts/kxm.mjs` runs the old bundle | `npm run build` |
## Next steps
- Understand what CI runs on your pull request: [CI and release](ci-and-release.md)
- Write or update a docs page: [Write KXM documentation](writing-docs.md)
- Find the test that proves a behavior: [Test matrix](test-matrix.md)
- Add a workspace package: [Packages and workspaces](packages.md)
- Look up a command: [CLI reference](../reference/cli-reference.md)