UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

1,922 lines • 108 kB
# KXM configuration file reference

This page describes every file a KXM project or operator configures: where it
lives, which parser reads it, every field that parser accepts, the error codes
it reports, and which commands read or write it. It was written against the
parsers in `plugins/kxm/src` and `scripts/`, and every example on
this page was validated with the commands named next to it. Where a field is
accepted but nothing acts on it yet, the tables say so.

Related pages:

- [Environment variables and limits](configuration.md) lists the environment
  variables for the hub, workers, and agents, and the protocol limits.
- [Workflow definitions](workflow-definitions.md) documents the hub's JSON
  webhook workflow definitions field by field.
- [Harness routing](harness-routing.md) explains when to run a model through its
  native harness and when to reach the same model through OpenRouter on Pi. This
  page documents the fields; that guide covers the decision.
- [Backup and restore](../operations/backup-and-restore.md) covers every path below.

## At a glance

| File | Schema id | Purpose | Who writes it | Tracked in Git? |
|---|---|---|---|---|
| `.kxm/project.yaml` | `kxm.project.v1` | Project identity, repositories, defaults, run limits | You; `kxm init` creates it | Yes |
| `.kxm/repo/repo.yaml` (in each repository) | `kxm.repository.v1` | Per-repository definition | You; `kxm init` creates the control one | Yes, in the repository it describes |
| `.kxm/project/env.yaml`, `.kxm/repo/env.yaml` | `kxm.environment.v1` | Portable, non-secret environment | You | Yes |
| `.kxm/agents/<id>.yaml` | `kxm.agent.v1` | Agent role and permission ceilings | You; `kxm init` creates two | Yes |
| `.kxm/models/<id>.yaml` | `kxm.model.v2` | One model route (harness, vendor, status, permissions) | You; `kxm init` | Yes |
| `.kxm/workflows/<id>.yaml` | `kxm.workflow.v1` | Ordered steps and typed transitions | You; `kxm init` creates `default` | Yes |
| `.kxm/gates.yaml` | `kxm.gate-registry.v1` | The executable gate registry | You; `kxm init` creates it | Yes |
| `.kxm/roles/<role>.yaml` | `kxm.role.v2` | Model rosters per role | You, `kxm role`, `kxm models` | Yes |
| `.kxm/routes.yaml` | `kxm.routes.v2` | Admitted and disabled model routes | You, `kxm routes`, `kxm models` | Yes |
| `.kxm/prices.yaml` | `kxm.prices.v1` | Dated, hash-pinned list prices | You | Yes |
| `.kxm/models/inventory.yaml` | `kxm.model-inventory.v1` | Discovered model catalog | `kxm models inventory-refresh` only | Your choice (generated) |
| `.kxm/config.yaml`, `~/.config/kxm/config.yaml` | `kxm.config.v1` | Personalization and hub auto-start | `kxm config set` | Project file: yes, unless ignored |
| `.kxm/modes.yaml` | `kxm.modes.v1` | Modes for `kxm explain` | You | Yes |
| `.kxm/template-provenance.yaml` | `kxm.template-provenance.v1` | Hashes of the built-in template | `kxm init` only | Yes |
| `.kxm/tasks/<id>.yaml`, `.kxm/goals/<id>.yaml` | `kxm.task.v1`, `kxm.goal.v1` | Work records | `kxm task`, `kxm goal` | Your choice |
| `.kxm/memory/*.md`, `.kxm/memory/candidates/*.md` | `kxm.memory.v1` | Project memory facts | You; `kxm memory note` writes candidates | Yes |
| `.kxm/skills/`, `.kxm/candidates/` | `kxm.skill-candidate.v1`, `kxm.candidate.v1` | Governed skills and improvement candidates | `kxm skills`, `kxm improve` | Yes |
| Webhook definitions (JSON file or variable) | none (JSON array) | Signed webhook workflows for the hub | You | Yes if stored in the repository, never with secrets |
| Claude Code plugin `userConfig` | Claude plugin manifest | Hub URL, token, agent identity for Claude Code | Claude Code, per user | No |
| `<state root>/update.yaml` | `kxm.update.v1` | Updater settings | You | No (host-local) |

The developer assignment runner reads `.kxm/roles/*.yaml` and `.kxm/models/*.yaml` at `refs/remotes/origin/main`.

`kxm init` does not write a `.gitignore`. See
[Workspace layout](#workspace-layout-tracked-ignored-and-state) for the entries
to add.

## How the files fit together

```text
.kxm/project.yaml ── repositories[] ──> .kxm/repo/repo.yaml (+ env.yaml) in each repository
   │ defaultWorkflow, defaultHarness, limits
   ▼
.kxm/workflows/<id>.yaml ── coordinator ──> .kxm/agents/planner.yaml
   │ steps[]
   ├─ kind agent | moa | approval | wait ──> .kxm/agents/<id>.yaml
   │                                           ├─ role ──> .kxm/roles/<role>.yaml
   │                                           │              └─ roster[].route ──> .kxm/models/<route>.yaml
   │                                           │                                    (harness, model, vendor, permissions)
   │                                           └─ tools, repositories, network: permission ceilings
   └─ kind gate ──> .kxm/gates.yaml: command | artifacts-exist | reserved

Live dispatch admission (checked by the Runtime for every attempt):
   agent.role ──> .kxm/roles/<role>.yaml roster ──> .kxm/models/<route>.yaml
   .kxm/routes.yaml holds only the admitted and disabled lists
   developer assignments (scripts/assignment-run.mjs) ──> .kxm/roles/*.yaml and .kxm/models/*.yaml

Cost accounting:
   producer token usage ──> .kxm/prices.yaml (dated today, hash verified) ──> list estimate
   workflow limits.maxModelCost ──> sums only attempts recorded with costBasis "metered"
```

### What validates what

| Check | Files it covers | Where it runs |
|---|---|---|
| Project bundle load: restricted YAML, JSON Schema, cross-file semantics | `project.yaml`, every `repo.yaml` and `env.yaml`, `agents/`, `models/`, `workflows/`, `gates.yaml`, `template-provenance.yaml`, plus the `roles/writer.yaml` cross-check | `kxm init` (validate mode), `kxm run` and `kxm run --dry-run`, `kxm trust`, every Runtime request |
| Retired agent routing fields | `harness` or `model` on `.kxm/agents/<id>.yaml` | Loader refuses the file with `retired_agent_routing_fields` (`routing resolves from role; remove model and harness`) |
| Workflow compile | `workflows/` | `kxm run` when it creates a run |
| Runtime acceptance | Workflow steps, gate definitions, the route selected from `agent.role`, `routes.yaml` (`admitted` and `disabled` only), `roles/` | `kxm runs drive` and live drives, per step |
| Permission diff | The bundle only | `kxm trust diff`, `kxm trust check` |
| Revisions pinned on every run | `configRevision` (the bundle), memory revision (`.kxm/memory` without `candidates/`, plus `.kxm/skills/promoted`), executor policy, tool policy (agent and step `tools` plus the gate registry) | `kxm run` |

Nothing validates `routes.yaml`, `roles/` (other than `writer.yaml`),
`roles`, `the role files`, `prices.yaml`, `inventory.yaml`,
`config.yaml`, `modes.yaml`, memory, tasks, goals, or webhook JSON during
`kxm init`. Their own readers report problems when they run. None of them are
part of `configRevision`, and `kxm trust check` does not see them: a change to
route admission or a role roster is not flagged as a permission expansion.

### Rules shared by the project bundle

These rules apply to the bundle files listed above.

- **Restricted YAML.** UTF-8, at most 256 KiB per file, nesting depth 32,
  64 KiB per scalar, 4,096 items per collection, 16,384 nodes, and 8,192
  mapping keys. Anchors (`anchor_forbidden`), aliases (`alias_forbidden`),
  custom tags (`tag_forbidden`), non-string keys (`non_string_key`), and
  duplicate keys (`invalid_yaml`) are rejected; YAML warnings are errors
  (`yaml_warning`); the root must be a mapping (`root_not_object`). Other
  files on this page use the general YAML parser, which accepts anchors.
- **Identity comes from the filename.** `.kxm/agents/reviewer.yaml` defines
  agent `reviewer`. Identifiers match `^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$`, are
  at most 64 characters, and cannot be a Windows device name such as `con` or
  `nul` (`resource_id_invalid`). The extension must be exactly `.yaml`; a
  `.yml` file is rejected (`resource_filename_invalid`). Subdirectories are
  rejected (`nested_resource_directory`), names that differ only by case
  collide (`resource_id_collision`), and symbolic links are rejected
  (`resource_symlink`, `resource_parent_symlink`).
- **Unknown fields fail closed** with `schema_additionalProperties`.
- **Schema errors** use the code `schema_<keyword>`, for example
  `schema_required`, `schema_enum`, `schema_const`, `schema_pattern`,
  `schema_type`, `schema_if`, `schema_not`, and `schema_oneOf`. A mistake inside
  a `oneOf` (gate definitions, transitions, model selectors) produces several
  errors at once; read them together.
- **Issues** are reported as `file: code: message` with a phase of
  `discovery`, `parse`, `schema`, `path`, `reference`, or `semantic`.
  `kxm init --json` returns them in `issues[]`.
- **Legacy JSON is refused.** `.kxm/config/agents.json`,
  `.kxm/config/gates.json`, or any `.kxm/config/workflows/*.json` makes the
  project unloadable (`legacy_state_unsupported`). If the bundle fails to load
  for any reason while a hub database exists at `.kxm/state/kxm.db`, `kxm init`
  reports mode `legacy` and adds `legacy_state_unsupported` to the real issues.
- **Where commands look.** `kxm init`, `kxm run`, and `kxm trust` find the
  project at the nearest Git root. `kxm routes`, `kxm role`, `kxm config`,
  `kxm memory`, `kxm task`, `kxm goal`, and `kxm explain` use the current
  directory (or `KXM_WORKDIR`). Run those from the project root; from a
  subdirectory they read or create a stray `.kxm/` there.
- **Commands rewrite whole files.** `kxm config set`, `kxm routes admit`, and
  the `kxm role` commands write the file back through a YAML serializer, so
  comments are dropped.

## `.kxm/project.yaml` (`kxm.project.v1`)

The authoritative project definition. Its presence at the Git root is what
makes a directory a KXM project. Parser: `loadKxmProject` in
`plugins/kxm/src/project-config.ts`; schema: `schemas/project.schema.json`.

| Field | Type and allowed values | Required, default | What reads it |
|---|---|---|---|
| `schema` | `kxm.project.v1` | Required | Loader |
| `id` | Opaque ID matching `^[a-z][a-z0-9]{1,15}_[A-Za-z0-9][A-Za-z0-9_-]{5,127}$` | Required | Loader, Runtime. `kxm init` generates `prj_` plus 32 hex characters; `--project-id` must start with `prj_`. See the note below |
| `name` | String, 1 to 120 characters | Required | Display only |
| `description` | String, at most 2,000 characters | Optional | Display only |
| `defaultWorkflow` | Identifier | Optional, `default` | Loader only: the named workflow must exist (`default_workflow_unknown`). `kxm run` always takes an explicit workflow. |
| `defaultExecutor` | `local`, `ssh`, or `exe-dev` | Optional | Loader (`executor_unknown`); recorded in the run's executor-policy revision |
| `defaultHarness` | `pi`, `claude`, `codex`, `grok`, `agy`, `kimi`, or `deepseek` | Optional, `pi` | Loader (`harness_unknown`); project default harness. An agent file does not set `harness`; that field is `retired_agent_routing_fields`. |
| `repositories` | Array of 1 to 64 entries | Required | Loader, Runtime |
| `repositories[].id` | Identifier | Required | Must be unique after case folding (`repository_id_collision`) |
| `repositories[].role` | `control` or `member` | Required | Exactly one `control` (`control_repository_count`) |
| `repositories[].required` | Boolean | Optional, `true` | A missing optional member is skipped |
| `repositories[].remoteIdentity` | String, at most 2,048 characters | Optional | Not read by any code path yet; diffed by `kxm trust` |
| `repositories[].defaultBranch` | String, at most 255 characters | Optional | Not read by any code path yet; diffed by `kxm trust` |
| `repositories[].pathHint` | Portable forward-slash relative path | Optional | The control repository must use `.`; a member path must stay inside the project root |
| `workspace.dirtySnapshot.untracked` | `bounded`, `tracked-only`, or `ask` | Optional | Not read by any code path yet; diffed as snapshot policy |
| `workspace.dirtySnapshot.maxUntrackedFileBytes` | Integer, 1 to 1,073,741,824 | Required when `untracked: bounded` | Not read by any code path yet |
| `workspace.dirtySnapshot.maxUntrackedTotalBytes` | Integer, 1 to 10,737,418,240 | Required when `untracked: bounded` | Not read by any code path yet |
| `workspace.dirtySnapshot.dirtySubmodules` | `fail` | Optional | Not read by any code path yet |
| `sync.prompts`, `sync.results`, `sync.evidence`, `sync.artifacts`, `sync.fileChanges` | Exactly `title-only`, `bounded-summary`, `references`, `metadata`, `paths-only` | Optional | Not read by any code path yet; diffed as sync policy |
| `sync.rawLogs`, `sync.diffs`, `sync.environmentValues` | Exactly `false` | Optional | Not read by any code path yet |
| `limits.maxConcurrentRuns` | Integer, 1 to 128 | Optional, `1` | Runtime admission. A changed bound is refused (`scheduler_policy_conflict`) while admitted or queued runs still use the previous one. |
| `limits.maxRunDurationMs` | Integer, 0 to 31,536,000,000 | Optional | Runtime. Combined with the workflow's own value; the smaller one wins. |
| `limits.maxAgentTimeMs` | Integer, 0 to 31,536,000,000 | Optional | Runtime refuses to drive any run while it is set (`limit_unsupported`); leave it out |
| `limits.agentStepTimeoutMs` | Integer, 60,000 to 31,536,000,000 | Optional, 3,600,000 | Wall clock for one live agent or moa step when the step omits `timeoutMs`. The supervisor passes it to the one-shot producer. A step `timeoutMs` narrower than this wins; a wider step value is refused (`step_unsupported`, field `timeoutMs`) |

The Runtime binds a project `id` to one repository. A git worktree of that
repository registers as a lane: the same id, its own control root, and the
same home runtime. A foreign clone with the same id is refused with
`project_home_conflict`. A registered root whose directory is gone is replaced
when a new root for that id registers.

Example (validated with `kxm init --json`, including a nested member checkout
at `repositories/api`):

```yaml
# .kxm/project.yaml — one per control repository.
schema: kxm.project.v1
id: prj_01JEXAMPLE0000000000000000   # opaque, stable; never reuse across projects
name: Payments Platform
description: Control project with one optional member repository.
defaultWorkflow: review               # must name a file in .kxm/workflows/
defaultExecutor: local                # local | ssh | exe-dev
defaultHarness: pi                    # pi | claude | codex | grok | agy | kimi | deepseek
repositories:
  - id: control                       # exactly one control repository
    role: control
    required: true
    pathHint: .                       # control must use "."
  - id: api
    role: member
    required: false                   # optional: a missing checkout is skipped
    remoteIdentity: ssh://git.example.test/payments/api.git
    defaultBranch: main
    pathHint: repositories/api        # portable forward-slash path under the project root
workspace:
  dirtySnapshot:
    untracked: bounded                # bounded | tracked-only | ask
    maxUntrackedFileBytes: 26214400   # required when untracked is bounded
    maxUntrackedTotalBytes: 262144000
    dirtySubmodules: fail             # the only accepted value
sync:
  prompts: title-only
  results: bounded-summary
  evidence: references
  artifacts: metadata
  fileChanges: paths-only
  rawLogs: false
  diffs: false
  environmentValues: false
limits:
  maxConcurrentRuns: 2
  maxRunDurationMs: 14400000
```

### Repository binding rules and error codes

The loader resolves each repository to a directory: the project root for the
control repository, a host-local binding from `kxm init --repository
<id>=<absolute path>` when one exists, or `pathHint` otherwise.

| Code | Meaning |
|---|---|
| `control_repository_count` | Not exactly one repository has `role: control` |
| `control_repository_path_invalid` | The control repository's `pathHint` is not `.` |
| `control_repository_binding_invalid` | A host-local binding for the control repository points somewhere other than the project root |
| `repository_id_collision` | Two repository IDs differ only by case |
| `portable_path_invalid` | `pathHint` is absolute, uses `\`, contains `.` or `..` segments, or names a Windows device |
| `repository_binding_path_link` | `pathHint` traverses a symbolic link or junction |
| `repository_binding_outside_project` | `pathHint` resolves outside the project root |
| `repository_binding_missing` | A required member has neither `pathHint` nor a host-local binding |
| `repository_binding_unavailable` | A required or explicitly bound repository directory does not exist |
| `repository_binding_invalid` | The binding is a link or not a directory |
| `repository_git_root_invalid` | A member binding is not the root of its own Git worktree |
| `repository_binding_collision` | Two repository IDs resolve to the same directory |
| `repository_binding_id_invalid`, `repository_binding_unknown`, `repository_binding_not_absolute` | A `--repository` binding names an invalid or undeclared ID, or a relative path |
| `default_workflow_unknown`, `executor_unknown`, `harness_unknown` | A default names something that does not exist or is not registered |

`kxm trust` can only diff a member that is a Git submodule (gitlink) at
`pathHint` or a host-local binding. A nested, ignored member checkout makes
`kxm trust` fail with `trust_scope_unsupported`.

Commands: `kxm init` creates, validates, repairs, and binds; `kxm run`,
`kxm trust diff|check`, `kxm tenant status`, and every Runtime request load it.

## `.kxm/repo/repo.yaml` (`kxm.repository.v1`)

One file per bound repository, stored in that repository: the control
repository's is `<project root>/.kxm/repo/repo.yaml`, and a member's is
`<member checkout>/.kxm/repo/repo.yaml`. Issues for it are reported under the
logical path `.kxm/repositories/<id>/repo.yaml`. It is required for every
required or explicitly bound repository (`repository_definition_missing`).

| Field | Type and allowed values | Required, default | What reads it |
|---|---|---|---|
| `schema` | `kxm.repository.v1` | Required | Loader |
| `projectId` | Opaque ID | Required | Must equal `project.yaml` `id` (`repository_project_mismatch`) |
| `repositoryId` | Identifier | Required | Must be declared in `project.yaml` (`repository_definition_unknown`) and match the binding it was found under (`repository_binding_identity_mismatch`) |
| `description` | String, at most 2,000 characters | Optional | Display only |
| `ecosystems` | Unique identifiers, at most 16 | Optional | Not read by any code path yet; diffed by `kxm trust` |
| `classification` | `public`, `internal`, or `sensitive` | Optional | Not read by any code path yet; diffed by `kxm trust` |
| `defaultAccess` | `none`, `read`, or `write` | Optional | Not read by any code path yet; diffed by `kxm trust`. Agent access ceilings come from agent files. |

```yaml
# .kxm/repo/repo.yaml — lives in the repository it describes.
schema: kxm.repository.v1
projectId: prj_01JEXAMPLE0000000000000000   # must equal project.yaml id
repositoryId: control                      # must match the project.yaml repositories[].id
description: Authoritative project configuration and application code.
ecosystems:
  - node
classification: internal                   # public | internal | sensitive
defaultAccess: write                       # none | read | write
```

Commands: the same as `project.yaml`.

## Environment files (`kxm.environment.v1`)

Portable, non-secret environment declarations. The project file is
`.kxm/project/env.yaml`. Any bound repository, the control repository
included, may also carry `.kxm/repo/env.yaml`, which requires a valid
`repo.yaml` in the same repository
(`repository_environment_without_definition`).

| Field | Type and allowed values | Required, default | Notes |
|---|---|---|---|
| `schema` | `kxm.environment.v1` | Required | |
| `values` | Map of name to string (at most 4,096 characters), number, or boolean; at most 256 entries | Optional | Names match `^[A-Z_][A-Z0-9_]*$` and may not contain `SECRET`, `TOKEN`, `PASSWORD`, `PASSWD`, `PRIVATE_KEY`, `API_KEY`, `CREDENTIAL`, `DATABASE_URL`, or `CONNECTION_STRING` (`schema_not`) |
| `secrets[].name` | Environment name | Required per entry | Unique (`secret_name_duplicate`); may not also appear in `values` (`environment_name_conflict`) |
| `secrets[].ref` | Identifier | Required per entry | A secret reference resolved outside Git |
| `secrets[].required` | Boolean | Optional, `true` | |
| `path.prepend`, `path.append` | Unique portable relative paths, at most 32 each | Optional | `portable_path_invalid` otherwise |

A value that looks like a credential (a private-key header, a `ghp_` or
`github_pat_` token, an `sk-` key, a Slack `xox` token, an AWS `AKIA` key, or a
JWT) is rejected with `probable_secret_value`.

No Runtime path applies these values to a process yet. They are validated,
pinned in `configRevision`, and diffed by `kxm trust` (values redacted).

```yaml
# .kxm/project/env.yaml — portable, non-secret environment for the project.
schema: kxm.environment.v1
values:
  CI: false
  NODE_OPTIONS: --max-old-space-size=4096
secrets:
  - name: TEST_DATABASE_URL     # variable name the process sees
    ref: test-database-url      # secret reference, resolved outside Git
    required: false
path:
  prepend:
    - tools/bin
```

## `.kxm/agents/<id>.yaml` (`kxm.agent.v1`)

One file per agent; the filename is the agent ID that workflow steps
reference. Schema: `schemas/agent.schema.json`; semantic checks in
`validateBundle` in `plugins/kxm/src/project-config.ts`.

An agent does not name a harness or a model. Routing starts at the agent's
`role`, continues through `.kxm/roles/<role>.yaml`, and uses the selected
`.kxm/models/<route>.yaml`. `harness` and `model` on an agent file fail load
with `retired_agent_routing_fields`.

| Field | Type and allowed values | Required, default | What reads it |
|---|---|---|---|
| `schema` | `kxm.agent.v1` | Required | Loader |
| `purpose` | String, 1 to 2,000 characters | Required | Display; neutral in `kxm trust` |
| `role` | Identifier | Optional | Loader: `.kxm/roles/<role>.yaml`, then the selected `.kxm/models/<route>.yaml` |
| `effort` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | Optional | Recorded on the agent. Dispatch effort comes from the attempt, not this field. |
| `skills` | Unique identifiers, at most 64 | Optional | Skill names granted to the agent |
| `instructions` | String, at most 16,000 characters | Optional | Not read by any code path yet. Step `instructions` are what reach the prompt. |
| `executor` | `local`, `ssh`, or `exe-dev` | Optional | Loader (`executor_unknown`); recorded in the executor-policy revision; no dispatch path selects an executor from it yet |
| `tools.preset` | `coordinator`, `read-only`, `workspace-writer`, or `tests-writer` | Optional | Loader (`tool_preset_unknown`): the preset must be registered. `coordinator` and `read-only` select the read-only one-shot profile. `workspace-writer` and `tests-writer` select the edit profile. An agent preset may only narrow the role. A wider preset is `tool_preset_exceeds_role` at load. |
| `tools.allow`, `tools.deny` | Unique identifiers, at most 128 each | Optional | A tool in both lists is `tool_policy_contradiction`; steps may only narrow the ceiling |
| `defaultRepositoryAccess` | `none`, `read`, or `write` | Optional; the ceiling is `none` when absent | Loader: access ceiling for repositories not listed in `repositories` |
| `repositories` | Map of repository ID to `none`, `read`, or `write`; at most 64 | Optional | Loader: per-repository access ceiling; IDs must be declared (`repository_unknown`) |
| `secrets[].ref` | Identifier | Required per entry | Loader: steps may only request refs granted here |
| `secrets[].as` | Environment name, `^[A-Z_][A-Z0-9_]*$` | Optional | Not read by any code path yet |
| `secrets[].required` | Boolean | Optional, `true` | Not read by any code path yet |
| `network` | `none`, `provider-only`, `restricted`, or `host` (ranked in that order) | Optional | Not enforced yet; `kxm trust` reports a move up the ranking as an expansion |
| `resultSchema` | String, at most 512 characters | Optional | Not read by any code path yet; diffed by `kxm trust` |
| `session.reuse` | `compatible-run-scope` | Optional | Not read by any code path yet |
| `session.maxIdleMs` | Integer, 0 to 31,536,000,000 | Optional | Not read by any code path yet |

Tool presets are names checked against a registered list, then mapped onto
the harness one-shot profiles in `plugins/kxm/src/harness.ts`. `coordinator`
and `read-only` use the read-only argument set (`READ_ONLY_ONESHOT_ARGS`).
`workspace-writer` and `tests-writer` use the audited writer set
(`WRITER_ONESHOT_ARGS`), which exists only for `pi` (`-a` with extensions,
skills, prompt templates and sessions off) and `grok` (`--always-approve`
with subagents and web search off). Both approve every tool call, shell
commands included, and neither confines the process to the checkout.

Dispatch permission is the narrowest of the role `permission`, the preset
profile (the role preset, then the agent preset), and the step's repository
access (`write` is edit; `read` and `none` are read-only). A preset cannot
widen a role. An agent preset that is wider fails load with
`tool_preset_exceeds_role`, and dispatch refuses that file again with
`step_unsupported` (field `tools.preset`) when the process already loaded
it. A write step whose resolved profile is read-only is `step_unsupported`
(field `permission`) before the producer starts. The detail names the role
permission and the step access. A live write that does resolve to edit
still hands off on a harness with no audited writer set; see
[Steps the Runtime does not execute yet](#steps-the-runtime-does-not-execute-yet).

`tools.allow` and `tools.deny` apply to the MCP surface through
`enforceToolPolicy` in `plugins/kxm/src/commands.ts`.

Example (the shape `kxm init` writes for `writer`):

```yaml
# .kxm/agents/writer.yaml. The filename is the agent id, and it matches role.
schema: kxm.agent.v1
purpose: Implement the approved change within the declared repository scope.
role: writer                      # .kxm/roles/writer.yaml -> .kxm/models/<route>.yaml
aliases: [implementer]            # deprecated id; still resolves, with a warning
tools:
  preset: workspace-writer        # coordinator | read-only | workspace-writer | tests-writer
  allow: [read, edit, write, bash]
  deny: [web_fetch]
defaultRepositoryAccess: none     # ceiling for repositories not listed below
repositories:
  control: write
secrets:
  - ref: npm-token                # secret reference name
    as: NPM_TOKEN                 # environment variable name inside the attempt
    required: false
network: provider-only            # none | provider-only | restricted | host
resultSchema: kxm.assignment-result.v1
```

`kxm init` creates `planner` (`role: planner`, alias `coordinator`) and `writer`
(`role: writer`, alias `implementer`) without `harness` or `model`, and admits
`anthropic/fable` and `xai/grok-4.6` in `.kxm/routes.yaml`. The model files are
`claude-fable` and `grok-grok-4-6`. An interactive `kxm init` can add
workflow-guide agents for reviewed pairs whose harness is authenticated, and
admits their selectors too, but skips Google guide candidates because the
Runtime cannot reach the `antigravity` Pi provider yet. `kxm run` and the
Runtime read the agents. `kxm trust` diffs them.

Error codes: `retired_agent_routing_fields`, `executor_unknown`,
`tool_preset_unknown`, `tool_preset_exceeds_role`, `tool_policy_contradiction`, `repository_unknown`, and
the path codes under
[Rules shared by the project bundle](#rules-shared-by-the-project-bundle).

## `.kxm/models/<id>.yaml` (`kxm.model.v2`)

One model route. The filename is the route id (`inventory.yaml` in the same
directory is the generated catalog and is skipped). `kxm config` validation
checks the file against `schemas/model.schema.json`. `origin` is required when
`harness` is `pi` and optional otherwise. When `origin` is present, `source`
is a project-relative evidence file and `sha256` is the SHA-256 of that file's
bytes.

| Field | Type and allowed values | Required, default | What reads it |
|---|---|---|---|
| `schema` | `kxm.model.v2` | Required | Loader |
| `id` | `<harness>-<model-slug>[-<provider>]`, 1 to 64 characters | Optional; the filename is the id | Loader. This repository's workforce lint requires the pattern |
| `aliases` | Deprecated route ids, at most 16 | Optional | Still resolve to this file; a warning names the canonical id |
| `harness` | Identifier, 1 to 64 characters | Required | Developer ceilings and dispatch membership |
| `model` | String, 1 to 200 characters | Required | Dispatch membership (`model`, `vendor/model`, `harness/model`) |
| `vendor` | String, 1 to 64 characters | Required | The lab that trained the model, not the biller |
| `status` | `admitted`, `candidate`, or `retired` | Required | A role roster may use only `admitted` |
| `permissions` | One or two of `edit`, `read-only` | Required | Must stay inside the harness ceiling |
| `origin.source` | String, 1 to 512 characters | Required with `origin.sha256` when `origin` is set; required for `harness: pi` | Evidence path |
| `origin.sha256` | 64 hex characters | Required with `origin.source` | Must match the evidence file |
| `thinking` | String, 1 to 64 characters | Optional | Recorded on the route |
| `tags`, `capabilities` | Unique identifiers, at most 32 | Optional | Selector tags |
| `priority` | Integer, -10,000 to 10,000 | Optional | Recorded on the route |
| `fallbacks` | Up to 8 `profile`, `tag` (optional `capabilities`), or `provider`+`model` objects | Optional | The opt-in walk expands each roster entry one level |
| `limits.contextTokens`, `limits.outputTokens` | Integer, at least 1 | Optional | Recorded on the route |
| `limits.timeoutMs` | Integer, 0 to 31,536,000,000 | Optional | Recorded on the route |

```yaml
schema: kxm.model.v2
id: grok-grok-4-7          # <harness>-<model-slug>; "." in the model id is "-"
aliases: [grok-native]     # deprecated id; still resolves, with a warning
harness: grok
model: grok-4.7
vendor: xai
status: admitted
permissions:
  - edit
origin:
  source: docs/reference/harness-routing.md
  sha256: 321c821ed6dbce7b2e98309fdc29387671577049619b6979c685a7ba60c63337
```

Commands: `kxm init`, `kxm run`, and `kxm trust` load these files. `kxm role modify --add-route` refuses a route id that has no file here (exit 1). Dispatch membership for a role reads `harness`, `model`, and `vendor`. The developer assignment runner reads `.kxm/roles/*.yaml` and `.kxm/models/*.yaml` at `refs/remotes/origin/main`.

## `.kxm/workflows/<id>.yaml` (`kxm.workflow.v1`)

An ordered list of steps with typed transitions. The filename is the workflow
ID used by `kxm run <workflow>`. Three layers check it:

1. The loader (`schemas/workflow.schema.json` plus `validateWorkflow` in
   `plugins/kxm/src/project-config.ts`) on every project load.
2. The compiler (`compileKxmWorkflow` in `plugins/kxm/src/engine-compile.ts`)
   when the first `kxm runs drive` starts the run. It pins the compiled plan
   on the run then, not when `kxm run` creates it, and it refuses the run
   (`run_revision_drift`) if the configuration changed in between.
3. Runtime acceptance (`plugins/kxm/src/engine.ts`) before each step executes.
   A step the current Runtime cannot execute hands the run off instead of
   running it.

### Top-level fields

| Field | Type and allowed values | Required, default | Notes |
|---|---|---|---|
| `schema` | `kxm.workflow.v1` | Required | |
| `description` | String, at most 4,000 characters | Optional | Prose; neutral in `kxm trust` |
| `coordinator` | Agent ID | Optional, `coordinator` | Must exist (`coordinator_unknown`); pinned on the compiled plan. See the note on approval and wait steps below. |
| `limits.maxTransitions` | Integer, 1 to 1,000 | Required once any back-edge exists (`workflow_cycle_unbounded`) | Run-wide transition budget; defaults to the number of steps. Exceeding it fails the run with `budget_transitions`. |
| `limits.maxRunDurationMs` | Integer, 0 to 31,536,000,000 | Optional | The Runtime cancels the run with `budget_run_duration`; the smaller of this and the project limit applies. Measured on the wall clock |
| `limits.maxAgentTimeMs` | Integer, 0 to 31,536,000,000 | Optional | The Runtime refuses to drive a run that declares it (`limit_unsupported`); see below |
| `limits.maxModelCost` | Number greater than 0 | Optional | Fails the run with `budget_model_cost` once attempts recorded with cost basis `metered` reach it. See [Cost basis](#cost-basis-and-staleness). |
| `limits.currency` | Three uppercase letters | Optional | Pinned on the plan; not otherwise read |
| `planHash` | `{stageId, evidenceKey}` | Optional | Validated and pinned, not enforced yet (see below). The step must declare that evidence key (`oracle_evidence_unknown`, `oracle_stage_unknown`). |
| `reproOracle` | `{stageId, evidenceKey}` | Optional | Same shape as `planHash`, for an immutable reproduction; validated and pinned, not enforced yet |
| `requirePlanHash` | Unique step IDs, at most 128 | Optional | Requires `planHash` (`plan_hash_missing`); every repository-writing step after the `planHash` stage must be listed (`mutation_missing_plan_hash`) |
| `steps` | 1 to 128 steps | Required | The first step is the entry point |

Approval and wait steps without `assignments.allowedAgents` are dispatched to the agent whose ID is literally `coordinator`, not to the `coordinator` field's value.

Duration budgets use the wall clock. `maxRunDurationMs` counts from the time the run first entered `running` (its first drive) to the current system time, so a change to the system clock moves it.

### Step fields

| Field | Type and allowed values | Required, default | Notes |
|---|---|---|---|
| `id` | Identifier | Required | Unique (`step_id_duplicate`) |
| `kind` | `agent`, `moa`, `gate`, `approval`, or `wait` | Required | `workflow` is reserved and rejected |
| `description` | String, at most 2,000 characters | Optional | Prose |
| `instructions` | String, at most 16,000 characters | Optional | Prepended to the generated prompt for the step |
| `agent` | Agent ID | Required for `agent` and `moa` | `agent_unknown`. When `assignments.allowedAgents` is set, it must include this agent (`primary_agent_ineligible`). |
| `gate` | Gate ID from `.kxm/gates.yaml` | Required for `gate` | `gate_unknown`; `gate_registry_missing` when there is no `gates.yaml` |
| `expect` | `pass` or `fail` | Optional, `pass`; gate steps only | `gate_expect_invalid` on other kinds |
| `signal` | Identifier | Required for `wait` | Compiled and diffed; the Runtime does not match signals to wait steps yet |
| `model` | Model selector | Optional | Intersected with each allowed agent's own model ceiling; an empty intersection is `model_selector_incompatible`. Live route resolution ignores it. The Runtime refuses it on gate steps. |
| `maxAttempts` | Integer, 1 to 20 | Optional, `1` | Entering the step again after this many attempts fails the run (`budget_step_attempts`) |
| `timeoutMs` | Integer, 0 to 31,536,000,000 | Optional | Not `0`. Gate steps: refused for `artifacts-exist` gates or below the gate's `timeoutMs`. Agent and moa steps: the one-shot spawn uses this value when it is at most `limits.agentStepTimeoutMs`; a wider value is refused (`step_unsupported`, field `timeoutMs`). When the step omits it, the project limit applies (default 3,600,000). The process default of 120 seconds is only the last resort when neither is set |
| `repositories` | Map of repository ID to `none`, `read`, or `write` | Optional | IDs must be declared (`repository_unknown`); may not exceed the agent's ceiling (`repository_scope_expansion`) |
| `tools` | `{preset, allow, deny}` | Optional | Must keep the agent's preset and denials and allow only tools the agent allows (`tool_scope_expansion`). The Runtime refuses steps that declare `tools`. |
| `secrets` | `[{ref, as, required}]` | Optional | Only refs the agent grants (`secret_scope_expansion`). The Runtime refuses steps that declare `secrets`. |
| `assignments` | See the next table | Optional | |
| `join` | See the next table | Optional, `{strategy: all}` | |
| `requiredEvidence` | Up to 64 requirements | Optional | See [Evidence](#evidence-requirements) |
| `safeSpeculation` | Boolean | Optional, `false` | Required `true` with `join.strategy: first-success`. The Runtime refuses it. |
| `on` | Map of outcome to transition, 1 to 32 entries | Required (`step_transitions_missing` when absent) | See [Transitions](#transitions-and-outcomes) |

Fields such as `role`, `area`, or `outcomes` are not part of this schema and
fail with `schema_additionalProperties`. `area` belongs to
[webhook workflow stages](workflow-definitions.md#stage-fields); the compiled plan
derives its outcome list from the keys of `on`.

### Assignments and join

| Field | Type and allowed values | Default | Notes |
|---|---|---|---|
| `assignments.allowedAgents` | 1 to 32 unique agent IDs | `[agent]`, or none on steps without `agent` | `assignment_agent_unknown`; every listed agent's ceilings are checked |
| `assignments.minimum` | Integer, 1 to 64 | `1` | `minimum <= target <= maximum` (`assignment_bounds_invalid`) |
| `assignments.target` | Integer, 1 to 64 | `minimum` | |
| `assignments.maximum` | Integer, 1 to 64 | `target` | For `moa`, at most the number of allowed agents (`assignment_pool_too_small`) |
| `assignments.maxParallel` | Integer, 1 to 64 | `maximum` | At most `maximum` (`assignment_parallelism_invalid`) |
| `assignments.maxAttemptsPerAssignment` | Integer, 1 to 20 | `1` | The Runtime accepts at most 2, but runs one attempt per assignment; a retry is a new assignment |
| `assignments.maxWriteRepositories` | Integer, 1 to 64 | none | At most the number of `write` repositories on the step (`write_repository_bound_invalid`); the Runtime executes at most 1 |
| `assignments.distinctBy` | Any of `provider`, `model`, `profile` | `[]` | Checked at load only: the candidates must offer `target` distinct values (`model_diversity_impossible`). The Runtime refuses values other than `provider` |
| `join.strategy` | `all`, `all-settled`, `quorum`, or `first-success` | `all` | The Runtime executes `all` and `all-settled` (see [How a panel joins](#how-a-panel-joins)) |
| `join.minimumPassed` | Integer, 1 to 64 | none | Required for `quorum`; at most `maximum` (`join_impossible`); the Runtime accepts it only with `all-settled` |
| `join.cancelRemaining` | Boolean | none | The Runtime refuses it |

At run time, members bind to agents in `allowedAgents` order: the first member runs the first listed agent, the second the second, and so on. The Runtime does not re-check providers, so `distinctBy: [provider]` holds at run time only if the first `target` agents in `allowedAgents` use different providers. List them that way.

#### How a panel joins

A panel joins once at least `assignments.minimum` members exist and every member's attempt has settled:

- If any member's result is unknown or rejected by its producer, the run fails with `outcome_unknown`.
- `all`: when every member produced the same outcome, the step takes it; otherwise the run fails with `join_conflict`.
- `all-settled`: when at least `join.minimumPassed` members (default `assignments.minimum`) passed, the outcome is `passed`. Otherwise, a non-passed outcome shared by that many members wins, and failing that the outcome is `quorum-not-met`.
- A joined outcome with no transition in `on` fails the run with `outcome_unknown`, so declare `quorum-not-met` on an `all-settled` step.

### Evidence requirements

| Field | Type and allowed values | Default | Notes |
|---|---|---|---|
| `key` | Identifier | Required | Unique per step (`evidence_key_duplicate`) |
| `kind` | `assignment-result`, `gate`, `receipt`, `approval`, or `artifact` | Required | |
| `minimum` | Integer, 1 to 16 | `1` | |
| `reusableAcrossAttempts` | Boolean | `false` | |
| `producerPolicy.minimumProducers` | Integer, 1 to 16 | Required in a policy | At most the step's `target` and the eligible count (`producer_minimum_impossible`) |
| `producerPolicy.eligibleAgents` | 1 to 32 agent IDs | Required in a policy | Must exist (`producer_agent_unknown`) and be in `allowedAgents` (`producer_agent_ineligible`) |
| `producerPolicy.acceptedStatuses` | Exactly `[passed]` | Required in a policy | |
| `producerPolicy.degradation.minimumProducers` | Integer, 1 to 15 | Optional | Must be lower than `minimumProducers` (`producer_degradation_invalid`) |

A `producerPolicy` is allowed only on `kind: assignment-result`.

The Runtime enforces less of this than the loader checks. `producerPolicy` (producer counts, eligible agents, degradation) is checked at load only. On agent and `moa` steps, each requirement only becomes an acceptance criterion in the prompt; nothing checks that the evidence arrived. Only gate steps record evidence at run time: one gate evidence reference for each settled gate attempt.

### Transitions and outcomes

Each key of `on` is an outcome identifier. Each value is either a step ID
(shorthand) or an object:

| Field | Type and allowed values | Notes |
|---|---|---|
| `target` | Step ID or `$terminal` | `transition_target_unknown` if the step does not exist |
| `maxTransitions` | Integer, 1 to 100 | Required on a back-edge, meaning a target at or before the current step (`back_edge_unbounded`). Exceeding it fails the run with `budget_edge`. |
| `terminalStatus` | `completed`, `failed`, or `cancelled` | Required when `target` is `$terminal` and forbidden otherwise |

Graph rules checked by the loader:

- Every step must be reachable from the first step (`step_unreachable`).
- Every reachable step must have a path to a terminal transition
  (`step_cannot_terminate`).
- No path that ends in `terminalStatus: completed` may skip a `gate` or
  `approval` step (`required_step_bypass`).
- If steps named `verify` and `ready` both exist, `verify` must transition to
  `ready` and no other step may (`verify_must_precede_ready`).
- On gate steps, `implementation_failure` and `repro_missing` are misspellings
  of `implementation-failure` and `repro-missing` (`gate_outcome_renamed`).
- A gate step settles only on `passed` or `implementation-failure` when
  `expect` is `pass`, and only on `passed` or `repro-missing` when `expect` is
  `fail`. A gate step is refused when it declares an outcome it never
  produces (such as `failed`) and also leaves an outcome it does produce
  undeclared (`gate_outcome_impossible`); the message names the outcomes to
  declare. An extra outcome next to every produced one is accepted, which is
  why the `verify` step `kxm init` writes, with `failed` beside
  `implementation-failure`, still loads.

Outcomes the Runtime produces:

| Step | Outcome |
|---|---|
| Command gate, `expect: pass` | Exit code 0 gives `passed`; any other exit gives `implementation-failure` |
| Command gate, `expect: fail` | Exit code 0 gives `repro-missing`; any other exit gives `passed` |
| `artifacts-exist` gate | All paths present gives `passed`; otherwise `implementation-failure` (`expect: fail` is refused) |
| Agent step | The producer asks the model for a JSON object whose `outcome` is one of the step's declared outcomes; anything else becomes `failed` |

Declare `passed` and `implementation-failure` on every `expect: pass` gate
step, and `passed` and `repro-missing` on every `expect: fail` gate step. A
gate step that routes a failure on `failed` instead of `implementation-failure`
is refused when the project loads (`gate_outcome_impossible`), so `kxm init`,
`kxm run`, and `kxm run --dry-run` report it before a run exists. Without that
check, a failing gate attempt could not settle: the Runtime records the
produced outcome, finds no transition for it, hands the run off with
`attempt_unsettled`, leaves it `running`, and holds later gate steps in the
same project with `gate_recovery_pending`.

The Runtime's drive-time pre-flight check is separate. It hands off
(`gate_outcome_undeclared`) an `expect: pass` gate step without `passed`, an
`expect: fail` gate step without `repro-missing`, and any gate step that
declares no failure outcome, accepting either `implementation-failure` or
`failed` as that outcome. An `expect: fail` gate step therefore also needs
`implementation-failure` or `failed` declared to be driven, although it never
produces either. Declare `failed` on every agent step, because the producer
falls back to it.

### Steps the Runtime does not execute yet

Validation accepts more than the Runtime executes. When a drive reaches one of
these, the run is handed off (`step_unsupported`, `gate_unsupported`, or
`limit_unsupported`) instead of executing, and `kxm runs drive` reports
`run_handoff_required`:

- `limits.maxAgentTimeMs` in the workflow or project. The current `kxm init`
  template leaves it out; older templates set it on `default`.
- `tools`, `secrets`, or `safeSpeculation: true` on any step.
- `join.strategy` other than `all` or `all-settled`, `join.minimumPassed` with
  `all`, and `join.cancelRemaining`.
- `assignments.maxAttemptsPerAssignment` above 2,
  `assignments.maxWriteRepositories` above 1, and `distinctBy` other than
  `provider`.
- A live (non-simulated) step with `write` access to any repository when its
  agent's harness has no audited writer profile (only `pi` and `grok` have
  one), when `assignments.maximum` is above 1, when the project's
  `limits.maxConcurrentRuns` is above 1, or when `.kxm/roles/writer.yaml` does not
  admit that route with `edit` permission. The checkout witness can attribute a
  change only to one writer at a time.
- Gate steps with `assignments.allowedAgents`, any assignment count or
  `maxAttemptsPerAssignment` other than 1, `distinctBy`,
  `maxWriteRepositories`, a join other than plain `all`, a `model`, no
  `repositories`, or the role of `planHash` or `reproOracle` stage; a command
  gate with no `write` repository; an `artifacts-exist` gate without `read` or
  `write` access to `control` or with access to any other repository; a
  `reserved` gate.

Some fields are validated and pinned on the plan but not enforced, and a
drive does not hand off on them:

- `planHash`, `reproOracle`, and `requirePlanHash`. The Runtime captures no
  hash and does not check one before a listed step runs. (Hub
  [webhook workflows](workflow-definitions.md#plan-hash-and-reproduction-oracle)
  do enforce their own `planHash` and `reproOracle`.)
- `distinctBy: [provider]` and `producerPolicy`, which are checked at load only
  (see [Assignments and join](#assignments-and-join) and
  [Evidence requirements](#evidence-requirements)).

The Runtime also caps each run at 100 attempts whose cost basis is `unmetered`
or `unknown` (`budget_unmetered_attempts`).

#### Handoff reasons

A handoff stops the drive without failing the run; `kxm runs drive` prints
the reason, field, and detail. The Runtime uses these reasons:

| Reason | Meaning |
|---|---|
| `step_unsupported`, `gate_unsupported`, `limit_unsupported` | The step, gate, or limit is one the Runtime does not execute yet (the list above) |
| `gate_outcome_undeclared` | The drive-time gate pre-flight check failed (see [Transitions and outcomes](#transitions-and-outcomes)) |
| `attempt_unsettled` | A gate attempt could not settle: its effect is uncertain, or its outcome has no transition |
| `gate_recovery_pending` | Unfinished gate attempts elsewhere in the project hold later gate steps |
| `attempt_unreconciled` | An issued attempt is not held by this process, for example after a supervisor restart |
| `cancel_pending_foreign` | Cancellation is pending in another process |
| `gate_uncertain_blocked` | The run is `blocked_uncertain` and waits for an external signal or an operator |

Example (validated with `kxm init --json` and compiled with
`compileKxmWorkflow`). Besides the `writer` and `reviewer-arch` agents and
the `critic-claude` profile shown above, it needs a `planner` agent (the
workflow coordinator; `coordinator` still resolves as an alias), a
`reviewer-cli` agent, and a second profile tagged `critic`, `critic-sol`
(`openai/gpt-5.6-sol`). Routes, not agent files, name the harness and model.
The two providers among the `critic` candidates satisfy
`distinctBy: [provider]` with `target: 2`.

```yaml
# .kxm/workflows/review.yaml — the filename is the workflow id.
schema: kxm.workflow.v1
description: Plan, implement, review with two critics, verify, approve, and wait for CI.
coordinator: planner               # agent id; omitted value falls back to "coordinator", an alias of planner
limits:
  maxTransitions: 12                # required once any back-edge exists
  maxRunDurationMs: 14400000
  maxModelCost: 25
  currency: USD
planHash:                           # capture the approved plan when "plan" passes
  stageId: plan
  evidenceKey: plan
requirePlanHash:                    # writing steps after "plan" must be listed here
  - writer
  - verify
steps:
  - id: plan
    kind: agent
    agent: planner
    description: Produce an implementation plan.
    instructions: Write the plan as a numbered list of file-level changes.
    maxAttempts: 2
    timeoutMs: 1200000
    repositories:
      control: read
    requiredEvidence:
      - key: plan
        kind: artifact
    on:
      passed: writer                # shorthand transition: a step id
      blocked:
        target: $terminal
        terminalStatus: failed

  - id: writer
    kind: agent
    agent: writer
    aliases: [implement]
    maxAttempts: 3
    repositories:
      control: write
    assignments:
      allowedAgents: [writer]
      minimum: 1
      target: 1
      maximum: 1
      maxParallel: 1
      maxAttemptsPerAssignment: 2
      maxWriteRepositories: 1
    requiredEvidence:
      - key: diff
        kind: artifact
    on:
      passed: review
      failed:
        target: $terminal
        terminalStatus: failed

  - id: review
    kind: moa                       # panel of agents
    agent: reviewer-arch            # primary agent; must be in allowedAgents
    model:
      tag: critic                   # intersected with each agent's own model ceiling
    repositories:
      control: read
    assignments:
      allowedAgents: [reviewer-arch, reviewer-cli]
      minimum: 2
      target: 2
      maximum: 2
      maxParallel: 2
      distinctBy: [provider]
    join:
      strategy: all-settled
      minimumPassed: 2
    requiredEvidence:
      - key: review
        kind: assignment-result
        producerPolicy:
          minimumProducers: 2
          eligibleAgents: [reviewer-arch, reviewer-cli]
          acceptedStatuses: [passed]
          degradation:
            minimumProducers: 1
    on:
      passed: verify
      changes-requested:            # back-edge: needs maxTransitions
        target: writer
        maxTransitions: 2

  - id: verify
    kind: gate
    gate: test                      # key in .kxm/gates.yaml
    expect: pass                    # pass | fail (gate steps only)
    maxAttempts: 2
    repositories:
      control: write
    requiredEvidence:
      - key: tests
        kind: gate
    on:
      passed: approve
      implementation-failure:
        target: writer
        maxTransitions: 2

  - id: approve
    kind: approval
    requiredEvidence:
      - key: signoff
        kind: approval
    on:
      passed: ci
      rejected:
        target: $terminal
        terminalStatus: cancelled

  - id: ci
    kind: wait
    signal: ci-checks
    timeoutMs: 86400000
    requiredEvidence:
      - key: ci
        kind: receipt
    on:
      passed:
        target: $terminal
        terminalStatus: completed
      failed:
        target: $terminal
        terminalStatus: failed
```

This example validates, but a live drive would be handed off at `writer`
(write access), at `review` (`reviewer-arch` uses a profile selector), and at
`approve` and `ci` (dispatched to `coordinator`, which resolves to `planner`). Use
it as a field reference; the
[worked example](#worked-example-a-minimal-two-step-project) is one that runs.

Commands: `kxm init` validates; `kxm run <id> [prompt]` creates a run
(`--dry-run` only loads the bundle); the first `kxm runs drive <runId>`
compiles and pins the plan, then drives it live, or without models with
`--simulated`; `kxm runs status|list|cancel`; `kxm trust` diffs.
`kxm workflow add <id> --template <name>` writes a built-in template
(`implement-and-verify`, `dual-critic-review`, or `spec-and-plan`), and
`kxm workflow add <id>` a one-step scaffold, under `.kxm/workflows/` (or
`~/.config/kxm/workflows/` with `--scope global`, which the loader never
reads). Both are valid `kxm.workflow.v1` definitions that use only what
`kxm init` creates: the `planner` and `writer` agents (`coordinator` and `implementer` remain aliases), the `control`
repository, and the `test` gate. The templates route gate failures on
`implementation-failure`. A local add is checked by the project loader first:
if the project would not load with the new file, it is refused with
`workflow_invalid` and nothing is written. Review the new file with
`kxm trust check` before committing it.

## `.kxm/gates.yaml` (`kxm.gate-registry.v1`)

The only place a gate ID becomes executable. A workflow gate step names a key
of `gates`. Schema: `schemas/gate-registry.schema.json`; the executable check is
in `validateBundle`; execution is in `plugins/kxm/src/engine-command.ts` and
`plugins/kxm/src/engine-artifacts.ts`.

| Field | Type and allowed values | Required, default | Notes |
|---|---|---|---|
| `schema` | `kxm.gate-registry.v1` | Required | |
| `gates` | Map of gate ID (identifier) to a definition, 1 to 64 entries | Required | |
| `gates.<id>.kind` | `command`, `artifacts-exist`, or `reserved` | Required | Each kind accepts only its own fields |
| `argv` (`command`) | 1 to 64 strings, each 1 to 4,096 characters, no NUL | Required | `argv[0]` must be a bare executable name or an absolute POSIX path (`gate_executable_invalid`). No shell is involved. |
| `timeoutMs` (`command`) | Integer, 1 to 2,147,483,647 | Required | The process is stopped when it expires |
| `cwd` (`command`) | `control` | Optional | The command always runs in the control repository root |
| `paths` (`artifacts-exist`) | 1 to 64 unique portable relative paths, not `.` | Required | Relative to `<control root>/.kxm/assets`; each must be a non-empty regular file that does not escape that directory |
| (`reserved`) | no other fields | | Declared but never executed; a step that reaches it is handed off with `gate_unsupported` |

A command gate runs `argv` with the Runtime supervisor's environment. Its exit
code decides the outcome (see
[Transitions and outcomes](#transitions-and-outcomes)); the Runtime records
hashes and byte counts of stdout and stderr, not their text.

Before it starts the process, the Runtime checks the joined `argv` against the
destructive-command seatbelt (`rm` with recursive and force flags,
`git reset --hard`, `git clean -f`, `git checkout --` with paths, and
`git restore .` or `*`); a match starts nothing and fails the run with
`gate_start_failed`. The command runs in its own
process group. On timeout or cancellation the Runtime sends `SIGTERM` to the
group, then `SIGKILL` after 2 seconds. A process that escapes the group can
outlive the gate, so the seatbelt and the group are cleanup, not a sandbox.

```yaml
# .kxm/gates.yaml — the only place a gate id becomes executable.
schema: kxm.gate-registry.v1
gates:
  test:
    kind: command
    argv: [npm, test]        # argv[0]: bare executable or absolute POSIX path; no shell
    timeoutMs: 1800000       # 1..2147483647
    cwd: control             # optional; the only accepted value
  release-notes:
    kind: artifacts-exist
    paths:                   # relative to <control root>/.kxm/assets
      - release/NOTES.md
  scm-delivery:
    kind: reserved           # declared but never executed
```

Error codes: `gate_executable_invalid`; `schema_oneOf` with the per-branch
`schema_*` errors for a malformed definition; in workflows, `gate_unknown` and
`gate_registry_missing`.

Commands: `kxm init` creates a `test` gate (`npm test`, one hour); the loader
validates it; the Runtime executes it; `kxm trust` diffs it, reporting a
`timeoutMs` change as a budget change. The gate registry is part of the tool
policy revision pinned on each run. `kxm gate artifacts-exist --path <file>` is a
separate CLI check against the workspace assets directory (`KXM_ASSETS_DIR`),
not this registry.

## `.kxm/roles/<role>.yaml` (`kxm.role.v2`)

One role. The filename is the role id. `kxm config` validation checks the file
against `schemas/role.schema.json`.

`listRoleBindings` reads `roster[].route`. An agent binds `role`, and dispatch
resolves harness, model, and effort from that role's roster and the matching
`.kxm/models/<route-id>.yaml` (`model`, `vendor/model`, or `harness/model`).
`kxm role` reads and writes these files. A file without
`schema: kxm.role.v2` is skipped by `kxm role`. A local file overrides a
global one with the same id.

| Field | Type | Required, default | What reads it |
|---|---|---|---|
| `schema` | `kxm.role.v2` | Required | Loader and `kxm role` |
| `purpose` | `writer`, `planner`, `reviewer-arch`, `reviewer-cli`, or `experiment` | Required | Developer ceilings |
| `permission` | `edit` or `read-only` | Required | Must be granted by every roster route |
| `description` | String, 1 to 2,000 characters | Required | `kxm role` |
| `roster[].route` | Kebab route id, 1 to 64 characters | Required per entry | Must name `.kxm/models/<route>.yaml` |
| `id` | Identifier | Optional; the filename | `kxm role` |
| `extends` | Identifier of another role in the same project | Optional | Refused when the chain cycles |
| `roster[].effort` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | Optional | Must be inside the harness ceiling when set |
| `roster[].mode` | `headless`, `interactive`, or `either` | Optional | Recorded on the entry |
| `policy.fallback.onError` | Unique `rate_limit`, `transport`, `provider_unavailable`, `context_overflow` | Optional; empty or absent means no walk | The attempt runner, only for a listed class |
| `policy.fallback.maxSwitches` | Integer, at least 0 | Optional; 1 when `onError` is non-empty and this field is omitted | The attempt runner. `0` allows no switch |
| `policy.fallback.revert` | `next_run` or `never` | Optional; `next_run` | `next_run` starts the next run on the first entry. `never` keeps the route that succeeded for later steps in the same run |
| `skills`, `tools`, `produces`, `consumes` | See `schemas/role.schema.json` | Optional | `kxm role` |
| `policy.vendorIndependenceRequired`, `policy.maxTransitions`, `policy.requiresGateVerification` | Boolean, or a positive integer for `maxTransitions` | Optional | Recorded on the role |

Roster order is preference. `kxm role list` shows the first route as the
primary. The Runtime checks membership. Harness, model, and effort resolve
from the agent's `role` roster and that route's `.kxm/models/<route-id>.yaml`,
never from the agent file. The developer assignment runner reads
`.kxm/roles/*.yaml` and `.kxm/models/*.yaml` at `refs/remotes/origin/main`.

When `policy.fallback.onError` lists a failure class, a failed attempt of that
class continues on the next admitted route before it settles. The chain is the
role roster (own entries, then `extends`), then one level of each route's
`fallbacks` selectors. Retired, disabled, and unhosted routes are skipped. A
live write step also requires `edit`, an audited writer profile, and a writer
roster binding. When `policy.vendorIndependenceRequired` is true, a later
route whose vendor matches `reviewer-arch` or `reviewer-cli` is skipped. The
engine appends `routing.route_switched` and logs `route_switch`. A bounded
redacted transcript from the failed attempt is appended to the next prompt and
is not stored on the event. Cancellation, policy refusal, authentication, an
unhosted model, a gate failure, tool policy, and admission errors never walk.
A role with an empty or absent `onError` list fails on the first route.

```yaml
schema: kxm.role.v2
id: writer
purpose: writer
permission: edit
description: Primary implementation agent.
roster:
  - route: grok-grok-4-7
    effort: medium
  - route: pi-qwen3-coder-plus-openrouter
    effort: medium
```

`kxm role add --route <route-id>` writes the roster. Repeat the option; the first id is primary. `kxm role modify <role> --add-route <route-id>` appends a route that already
has a model file. `--remove-route <route-id>` drops one. Validated with
`kxm config` and `kxm role list --json`.

`kxm models` does not write a v1 roster entry (a model id with an enabled flag). Its `r` and `x` keys call `setRouteState`, which appends `{route}` only when a `kxm.model.v2` file matches the inventory id. A selector with no matching model file is the `unknown route` path: the role file is left unchanged.

Commands: `kxm role list|get|add|remove|modify` (`--scope global|local`, and `add --route`, `modify --add-route`, `modify --remove-route`);
the Runtime reads rosters on every live attempt.

## `.kxm/routes.yaml` (`kxm.routes.v2`)

The route admission list the Runtime checks before every live attempt.
Parser: `loadRoutePolicy` in `plugins/kxm/src/routes.ts` (general YAML parser;
unknown keys are ignored).

| Field | Type | Required, default | What reads it |
|---|---|---|---|
| `schema` | `kxm.routes.v2` | Required | Anything else fails with `invalid .kxm/routes.yaml` |
| `admitted` | Array of route strings | Required | Runtime route check; `kxm routes list`, `kxm routes count`; `kxm models` |
| `disabled` | Array of route strings | Optional, `[]` | Runtime: a disabled route is refused even if admitted |
| `updatedAt` | ISO timestamp string | Optional | Rewritten by every CLI change |

A route string is derived from the selected model file's `vendor` and
`model`, joined by a slash: `xai/grok-4.6`, `anthropic/fable`,
`openrouter/qwen/qwen3-coder-plus`. When the file is missing, nothing is
admitted and every live attempt is refused (`producer_route_unsupported`, or
`producer_route_not_admitted` from the live producer). A leftover
`.kxm/producers.yaml` makes every reader fail with `retired
.kxm/producers.yaml present; use .kxm/routes.yaml (kxm.routes.v2)`.

`kxm routes admit|disable --model <id>` only accepts an ID that appears in
`.kxm/models/inventory.yaml`; otherwise it exits 2 with
`model_selection_required`. Inventory IDs come in several shapes (bare
`grok-4.6` from `grok models`, `vendor/model` from OpenRouter,
`provider/model` from `pi --list-models`), so a route such as
`openrouter/qwen/qwen3-coder-plus` or `anthropic/fable` usually has to be
added by editing the file.

```yaml
schema: kxm.routes.v2
updatedAt: '2026-09-23T00:00:00.000Z'
admitted:
  - anthropic/fable
  - openai/gpt-5.6-sol
  - xai/grok-4.6
  - openrouter/qwen/qwen3-coder-plus
disabled: []
```

Validated with `kxm routes list --json` and `kxm routes count --json`. `policy` has `admitted` and `disabled`. Membership comes from `.kxm/roles/*.yaml`, not from this file.

Commands: `kxm routes list|count|admit|disable` (`--dry-run` supported for
changes), `kxm models` (interactive), the Runtime, and the live producer.
Route changes are not part of `configRevision` and `kxm trust check` does not
report them; review them in the pull request diff.

## Developer assignment policy

The developer roster for `scripts/assignment-run.mjs` (`kxm assign`; see
[Assignment runner](../contributing/assignment-runner.md)). It applies to the KXM
source repository itself: the loader in `scripts/roster-policy.mjs` reads
`.kxm/roles/*.yaml` and `.kxm/models/*.yaml` committed at `HEAD` of the
repository that contains the script, and refuses unless the worktree is clean,
`HEAD` is an ancestor of `origin/main`, and each working file is byte-identical
to the committed one. It has no dispatch authority in the project Runtime.
The assembled object has `routes`, `lineup`, `required_critics`, and `model_origins`.

| Field | Type and allowed values | Notes |
|---|---|---|
| source files | `.kxm/roles/*.yaml` (`kxm.role.v2`), `.kxm/models/*.yaml` (`kxm.model.v2`) | The loader assembles the object below from these files |
| `routes.<id>` | Route ID matching `^[a-z0-9]+(?:-[a-z0-9]+)*$` | At least one route |
| `routes.<id>.harness` | `grok`, `agy`, `claude`, `codex`, or `pi` | Other harnesses are refused (`unsupported harness`) |
| `routes.<id>.model` | Token without whitespace | Native harnesses: a bare model ID. Pi: `openrouter/<vendor>/<model>`, `nous-portal/<vendor>/<model>`, or `antigravity/gemini-<id>` |
| `routes.<id>.vendor` | Token | The model vendor, not the billing provider. Aliases: `x-ai` is `xai`, `moonshotai` is `moonshot`, `google-ai` is `google`, `qwen` is `alibaba`. |
| `routes.<id>.roles` | Unique subset of the harness's roles | grok: `writer`; agy: `writer`, `experiment`; claude: `planner`, `reviewer-arch`; codex: `reviewer-cli`; pi: all five |
| `routes.<id>.permissions` | Unique subset of the harness's permissions | grok and agy: `edit`; claude and codex: `read-only`; pi: `read-only`, `edit` |
| `routes.<id>.status` | `admitted` or `retired` | |
| `lineup.<role>` | Unique route IDs | Roles: `writer`, `planner`, `reviewer-arch`, `reviewer-cli`, `experiment`. The first four are required. Every listed route must be admitted for that role. |
| `required_critics.review-arch`, `required_critics.review-cli` | Route IDs | Exactly these two keys; each must be in the matching reviewer lineup, `read-only`, and the two vendors must differ |
| `model_origins.<model>` | `{vendor, evidence}` | Required for every Pi route model |
| `model_origins.<model>.evidence` | `{source, sha256}` or `{source, commit, sha256}` | `source` is a repository-relative file; its bytes at `commit` (or at `HEAD`) must hash to `sha256`, and `commit` must be in trusted history |

Other refusals, all prefixed `Roster policy refused:`: `native vendor cannot
use Pi` (a Pi route to anthropic, openai, xai, moonshot, google, or deepseek),
`native route vendor/model mismatch`, `Pi writer requires edit permission
only`, `Pi critic/planner cannot edit`, `writer and critics must have
independent vendors`, and `retired .kxm/roster.json present`.

```yaml
# Assembled by scripts/roster-policy.mjs from .kxm/roles/*.yaml and .kxm/models/*.yaml.
# Route ids are <harness>-<model-slug>[-<provider>]. The first critic in each
# lineup is the required critic. Later entries are read-only fallbacks.
routes:
  grok-grok-4-7:
    harness: grok
    model: grok-4.7             # native harnesses take a bare model id
    vendor: xai
    roles: [writer]
    permissions: [edit]
    status: admitted            # admitted | retired
  pi-qwen3-coder-plus-openrouter:
    harness: pi
    model: openrouter/qwen/qwen3-coder-plus   # Pi: allowed provider prefix + vendor/model
    vendor: alibaba
    roles: [writer]
    permissions: [edit]
    status: admitted
  claude-fable:
    harness: claude
    model: fable
    vendor: anthropic
    roles: [planner, reviewer-arch]
    permissions: [read-only]
    status: admitted
  codex-gpt-5-6-sol:
    harness: codex
    model: gpt-5.6-sol
    vendor: openai
    roles: [reviewer-cli]
    permissions: [read-only]
    status: admitted
lineup:                         # routes admitted for each role, preference order
  writer: [grok-grok-4-7, pi-qwen3-coder-plus-openrouter, agy-gemini-3-8-flash-high, agy-gemini-3-8-flash-medium]
  planner: [claude-fable, pi-qwen3-8-flash-openrouter]
  reviewer-arch: [claude-fable, pi-glm-5-3-flash-openrouter]
  reviewer-cli: [codex-gpt-5-6-sol, pi-qwen3-8-flash-openrouter]
required_critics:
  review-arch: claude-fable
  review-cli: codex-gpt-5-6-sol
model_origins:                  # required for every Pi route model
  openrouter/qwen/qwen3-coder-plus:
    vendor: alibaba
    evidence:
      source: plans/evidence/route-qwen-openrouter-pi.md
      sha256: b15f8a54adc398b33e75609e959076eee310bf4ff6623d0b8470ea3da95e6c54
```

Validated with `validateRosterDocument` from `scripts/roster-policy.mjs`,
reading evidence from this repository's history. The lineup order is not a
selection order: the assignment manifest names the harness and model, and the
runner checks that the pair is admitted in the lineup.

## `.kxm/prices.yaml` (`kxm.prices.v1`)

A dated, hash-pinned snapshot of list prices in USD per million tokens.
Parser: `parsePriceCatalog` in `plugins/kxm/src/prices.ts`; cost math in
`plugins/kxm/src/price-calc.ts`. The loader looks for `<root>/.kxm/prices.yaml`,
then `<root>/prices.yaml`.

| Field | Type and allowed values | Required, default | Notes |
|---|---|---|---|
| `schema` | `kxm.prices.v1` | Required | |
| `date` | `YYYY-MM-DD`, a real calendar date | Required | Compared with today's UTC date; see below |
| `sha256` | 64 lowercase hex characters, optionally prefixed `sha256:` | Required | Must equal the canonical digest (`price catalog hash mismatch`) |
| `currency` | `USD` | Optional, `USD` | Any other value is refused |
| `models` | Non-empty array | Required | |
| `models[].id` | Non-empty string | Required | Unique (`duplicate price catalog model id`); usually `provider/model` |
| `models[].provider` | Non-empty string | Required | A lookup that names a provider only matches rows with that provider |
| `models[].model` | Non-empty string | Required | |
| `models[].aliases` | Array of strings | Optional | Also matched, case-insensitively, against the requested model |
| `models[].tiers` | Non-empty array | Required | |
| `tiers[].upToContextTokens` | Positive integer, or `null`/absent for unbounded | Optional | Bounds must strictly increase; the unbounded tier must be last |
| `tiers[].inputPerMillion` | Finite number, at least 0 | Required | The parser also accepts the short name `input` |
| `tiers[].outputPerMillion` | Finite number, at least 0 | Required | Short name `output` |
| `tiers[].cacheReadPerMillion` | Finite number, at least 0, or `null` | Optional | Short name `cacheRead` |
| `tiers[].cacheWritePerMillion` | Finite number, at least 0, or `null` | Optional | Short name `cacheWrite` |

The parser ignores unknown fields; `schemas/prices.schema.json` forbids them
but is not enforced. Use the long field names: the short names parse, but the
hash recipe below only works on the long ones.

### How `sha256` is computed

The digest is SHA-256, in hex, over `JSON.stringify` of this object (key order
as shown):

1. `schema`, `date`, and `currency` (`USD` when absent).
2. `models`, sorted by `id` (locale compare). Each model is `id`, `provider`,
   `model`, `aliases` (sorted; `[]` when absent), and `tiers`.
3. Each tier is `upToContextTokens`, `inputPerMillion`, `outputPerMillion`,
   `cacheReadPerMillion`, and `cacheWritePerMillion`, with `null` for any
   absent optional value.

Comments, YAML formatting, and the order of fields in the file do not change
the digest. To compute it from a source checkout, run this from the repository
root and paste the output into `sha256`:

```bash
node --disable-warning=ExperimentalWarning --experimental-strip-types --input-type=module -e '
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { hashPriceCatalog } from "./plugins/kxm/src/prices.ts";
const { sha256, ...body } = parse(readFileSync(process.argv[1], "utf8"));
console.log(hashPriceCatalog(body));
' /path/to/project/.kxm/prices.yaml
```

### Cost basis and staleness

- The producers only use a catalog whose `date` is today's UTC date. An older
  snapshot is treated as stale (`priceCatalogStale: true` in the attempt's
  provider metadata) and contributes nothing. A catalog that fails to parse or
  verify is marked `priceCatalogUnavailable: true`. A missing file contributes
  nothing and sets no flag.
- A current catalog adds a list-price estimate to the attempt's metadata
  (`listCostUsd`, `listPriceRef` of the form `<date>#<id>`, `listPriceSha256`).
  It needs all four token counts, and one-shot harnesses produce one only for
  `claude` with a single unbounded tier. Without a context measurement only a
  single unbounded tier can be priced; with one, the first tier whose bound is at
  least the context size applies. Cache tokens against a `null` rate, or a
  missing tier, yield no estimate rather than zero.
- The built-in producers always record `costBasis: unknown` (Pi, and one-shot
  harnesses on API credentials) or `unmetered` (one-shot harnesses signed in
  with a subscription, and the simulated producer), with `costUsd: null`. A list
  estimate never becomes a metered cost.
- `limits.maxModelCost` sums only `metered` attempts, so with the built-in
  producers it does not trip. The Runtime caps `unmetered` and `unknown`
  attempts at 100 per run instead.

Example (validated by `parsePriceCatalog`; the rates are illustrative):

```yaml
# .kxm/prices.yaml — dated list-price snapshot, USD per million tokens.
schema: kxm.prices.v1
date: "2026-09-23"                 # UTC calendar date of the snapshot
sha256: 628004d20c0487e0196fcdea53235d085282929260ba479258a8a6999a4dff6e
currency: USD                      # only USD is accepted
models:
  - id: anthropic/fable            # unique; matched against the requested model
    provider: anthropic
    model: fable
    aliases:
      - claude-fable-5
    tiers:
      - inputPerMillion: 10          # a single unbounded tier (no upToContextTokens)
        outputPerMillion: 50
        cacheReadPerMillion: 0.25
        cacheWritePerMillion: 12.5
  - id: example/tiered-model
    provider: example
    model: tiered-model
    tiers:
      - upToContextTokens: 200000    # applies while context <= 200000 tokens
        inputPerMillion: 1.25
        outputPerMillion: 10
        cacheReadPerMillion: 0.125
        cacheWritePerMillion: null   # null = no published rate
      - inputPerMillion: 2.5         # unbounded tier must be last
        outputPerMillion: 15
        cacheReadPerMillion: 0.25
```

Commands: the one-shot producer, which the Runtime uses for every harness,
reads it on every attempt and ignores a catalog not dated today (the package's
long-lived Pi producer also reads it, but nothing in the Runtime calls that
producer today); `kxm explain` reports `catalogStatus` (`verified`, `stale`,
`corrupt`, or `missing`); `kxm routing report --list-prices [--prices <file>]`
reads it (default `<workspace>/prices.yaml`, normally `.kxm/prices.yaml`) and
ignores a bad file. The report skips the freshness check, so it prices with a
catalog of any date, including one the producers treat as stale.

## `.kxm/models/inventory.yaml` (`kxm.model-inventory.v1`)

A generated catalog of models the machine can see, with public list prices.
Never hand-edit it: `kxm models inventory-refresh` (alias `refresh`) rewrites
the whole file. No parser validates it; `kxm routes admit|disable` and the
interactive `kxm models` screen read only `models[].id`.

The refresh runs `pi --list-models`, `grok models`, and `agy models`, and
fetches `https://openrouter.ai/api/v1/models` (override with
`KXM_OPENROUTER_MODELS_URL`, authenticated with `OPENROUTER_API_KEY` when set)
and `https://inference-api.nousresearch.com/v1/models` (override with
`KXM_NOUS_MODELS_URL`, `NOUS_API_KEY`). It makes network requests, writes the
file even when a source fails, and exits 1 if any source failed.
`--dry-run` writes nothing.

| Field | Contents |
|---|---|
| `schema` | `kxm.model-inventory.v1` |
| `fetchedAt` | ISO timestamp of the refresh |
| `currency` | `USD` |
| `sources.<name>` | `url` (a command line or URL), `ok`, and `error` on failure, for `pi`, `grok`, `agy`, `openrouter`, and `nous` |
| `models[].id` | Model ID as the source reported it, sorted |
| `models[].name`, `models[].contextLength` | From the first source that reported them |
| `models[].capabilities.thinking` | `supported` (true or null), optional `levels` and `default`, and `source` |
| `models[].capabilities.speed` | `fast` (true or null), optional `tiers`, and `source` |
| `models[].sources` | Which sources listed the model |
| `models[].standard` | OpenRouter per-million rates: `inputPerMillion`, `outputPerMillion`, `cacheReadPerMillion`, `cacheWritePerMillion` |
| `models[].discount` | Nous per-million rates, same fields |

The inventory is discovery data, not admission. Admitting a route is a
separate, reviewed change to `.kxm/routes.yaml`.

## Personalization settings (`kxm.config.v1`)

Personal and workflow preferences, merged in three layers: built-in defaults
in `plugins/kxm/src/config.ts`, then the user file
`$KXM_USER_CONFIG_DIR/config.yaml` (default `~/.config/kxm/config.yaml`), then
the project file `.kxm/config.yaml` in the current directory. Later layers win
key by key; arrays are replaced, not merged. The files need no `schema` key.
`hub.autoStart` and the `improvement.*` keys fall back to their defaults field
by field (see the table). `hub.mode`, `hub.local`, `hub.cloud`, and
`hub.projects` are validated: a literal token is refused with
`hub_config_invalid`, and a mode other than `local` or `cloud` is refused. A
file that is not valid YAML makes every `kxm config` command fail. `kxm improve`
loads the project file from its project root (the current directory's Git root
when it holds `.kxm/project.yaml`) rather than from the current directory.

The keys that change behavior are the `hub` section below and the
`improvement.*` keys that shape the report `kxm improve` prints. Hub URL, mode,
project id, and key reference live here rather than in `.kxm/project.yaml`
because `hub.autoStart` was already this file's hub setting, and
`project.yaml` stays the stable hashed project id. The **Read by** column lists
every reader found in `plugins/kxm/src`, `scripts/`, and `packages/`; the
`kxm config` commands themselves are not counted.

| Key | Type and allowed values | Default | Read by |
|---|---|---|---|
| `hub.autoStart` | `background` or `off`; any other value falls back to `background` | `background` | The Pi extension on load (`plugins/kxm/src/extension.ts`, `hub-autostart.ts`) |
| `hub.mode` | `local` or `cloud`. Any other value is `hub_config_invalid`. Unset means local unless a cloud binding exists | unset | Every hub client (`hub-identity.ts`): CLI, Pi extension, MCP server, dash, and `kxm agent worker` |
| `hub.local`, `hub.cloud` | `url` (http or https, no userinfo, query, or fragment), `project` (one line, no spaces), and `key` | unset | The same clients, for the active mode. `project` defaults to the `.kxm/project.yaml` id when omitted |
| `hub.local.key`, `hub.cloud.key`, `hub.projects.<id>` | `op` is `op://vault/item/field`. `env` is an environment variable name. A string, or a field named like a token, is refused | unset | Clients resolve the active mode's key at use time. The hub process resolves `hub.projects` in memory (`server.ts`). Neither writes the resolved token |
| `user.name`, `user.email` | String | none | Not read by any code path yet |
| `user.preferredHarness`, `user.preferredModel` | String | none | Not read by any code path yet |
| `user.preferredCritics` | Array of strings | `[reviewer-arch, reviewer-cli]` | Not read by any code path yet |
| `user.theme` | `dark`, `light`, or `minimal` | `dark` | Not read by any code path yet |
| `user.tokenBudget` | Number | `16000` | Not read by any code path yet (`kxm context get --budget` takes its own value) |
| `defaults.project`, `defaults.model` | String | none | Not read by any code path yet |
| `defaults.workflow` | String | `software-engineering/feature-implementation` | Not read by any code path yet |
| `defaults.harness` | String | `pi` | Not read by any code path yet; the harness default that takes effect is `defaultHarness` in `.kxm/project.yaml` |
| `dash.defaultScreen` | `agents`, `tasks`, `workflows`, `plans`, `inbox`, `procs`, or `spend` | `agents` | Not read by any code path yet |
| `dash.refreshIntervalMs` | Number | `1000` | Not read by any code path yet |
| `dash.autoOpen` | Boolean | `false` | Not read by any code path yet |
| `sync.defaultTracker` | `github`, `jira`, or `none` | `none` | Not read by any code path yet |
| `sync.github.owner`, `.repo`, `.syncLabels`, `.autoComment` | String or boolean | none | Not read by any code path yet |
| `sync.jira.host`, `.projectKey`, `.issueType`, `.autoTransition` | String or boolean | none | Not read by any code path yet |
| `improvement.promotionPolicy` | `manual_pr`, `critic_quorum`, or `auto_threshold`; any other value falls back to `manual_pr` | `manual_pr` | `kxm improve` (`cmdImprove` in `plugins/kxm/src/cli/system.ts`, then `evaluatePromotionPolicy` in `improve.ts`): selects the review-readiness rule reported per candidate. No value authorizes or activates anything |
| `improvement.telemetryHalfLifeDays` | Number greater than 0 and at most 3650; otherwise `14` | `14` | `kxm improve`: the half-life of each record's weight in `weightedRecurrence`, which orders report rows and never decides candidacy |
| `improvement.autoThreshold.minRuns`, `.minPassRate`, `.minCostSavings` | `minRuns` an integer from 1 to 1,000,000, `minPassRate` from 0 to 1, `minCostSavings` at least 0; otherwise the default | `10`, `0.95`, `0.5` | `kxm improve`, only under `auto_threshold` (see below) |
| `routing.shadowExecution.enabled` | Boolean | `false` | Not read by any code path yet |
| `routing.shadowExecution.sampleRate` | Number | `0.05` | Not read by any code path yet |
| `routing.shadowExecution.candidateModels` | Array of strings | `[]` | Not read by any code path yet |
| `routing.circuitBreaker.mode` | `soft_demotion` or `quarantine` | `soft_demotion` | Not read by any code path yet (`evaluateCircuitBreaker` in `routing.ts` has no production caller) |
| `routing.circuitBreaker.failureThreshold`, `.windowSeconds`, `.cooldownSeconds`, `.penaltyMultiplier` | Number | `3`, `3600`, `1800`, `5` | Not read by any code path yet |
| `telemetry.federated` | Boolean | `true` | Not read by any code path yet (`exportFederatedTelemetry` has no production caller) |
| `telemetry.anonymize` | Boolean | `true` | Not read by any code path yet |
| `telemetry.userTelemetryDir` | Path | none | Not read by any code path yet |

Under `auto_threshold`, `minRuns`, `minPassRate` and `minCostSavings` are the
distinct runs, the accepted share, and the mean recorded cost per attempt a
candidate needs before `kxm improve` reports it ready for review. A group with
no recorded cost is never ready.

```yaml
# .kxm/config.yaml (project scope) or ~/.config/kxm/config.yaml (user scope).
# The project file wins. Key fields are references, never tokens.
# --token-command is not a config field. It lives only in the machine-wide
# hub-binding.cloud.json written by `kxm hub bind --cloud`.
hub:
  autoStart: background        # background | off
  mode: local                  # local | cloud
  local:
    url: http://127.0.0.1:7331
    project: prj_example
    key:
      op: op://Private/kxm/local-project-token
      env: KXM_LOCAL_PROJECT_TOKEN
  cloud:
    # Clients use an SSH forward. https://hub.kxmd.dev is an Authentik login page.
    url: http://127.0.0.1:17331
    project: prj_example
    key:
      op: op://Private/kxmd/project-token
      env: KXMD_HUB_TOKEN
  projects:
    prj_example:
      op: op://Private/kxm/local-project-token
      env: KXM_LOCAL_PROJECT_TOKEN
improvement:
  promotionPolicy: manual_pr   # manual_pr | critic_quorum | auto_threshold (readiness only)
  telemetryHalfLifeDays: 14
user:
  preferredHarness: grok
  theme: dark                  # dark | light | minimal
defaults:
  workflow: review
  harness: pi
routing:
  circuitBreaker:
    mode: soft_demotion        # soft_demotion | quarantine
    failureThreshold: 3
telemetry:
  federated: true
  anonymize: true
```

Validated with `kxm config list --json` and `kxm config get`.

Commands:

- `kxm config get <key>` prints one merged value. Top-level sections other
  than the ones in the table are dropped, so `kxm config get no.such.key`
  prints `(undefined)` even after `kxm config set` wrote it.
- `kxm config set <key> <value> [--scope user|project]` writes one file
  (project by default). The value is parsed as JSON when it can be (`true`,
  `5`, `["a"]`), otherwise stored as a string. Keys are not validated.
- `kxm config list` prints `user`, `defaults`, `dash`, `sync`, and
  `loadedFrom`; `--json` prints every section. An empty `loadedFrom` means no
  file was found.

There is no `kxm config unset`. Delete the key from the file to fall back to
the next layer; setting `null` stores `null`.

### Configuration layers

Preferences resolve through three file layers, and environment variables configure the processes beside them without overriding any `kxm.config.v1` key.

```mermaid
flowchart LR
  D["Built-in defaults<br/>config.ts"] -->|"overridden by"| U["User layer<br/>~/.config/kxm/config.yaml"]
  U -->|"overridden by"| P["Project layer<br/>.kxm/config.yaml"]
  P --> V["Merged view<br/>kxm config list"]
  CU["kxm config set --scope user"] -->|writes| U
  CP["kxm config set<br/>(project scope, the default)"] -->|writes| P
  E["Environment variables<br/>KXM_*"] -.->|"KXM_USER_CONFIG_DIR moves it"| U
  E -->|"configure; never override these keys"| S["Hub, agent, worker<br/>and Runtime processes"]
  HB["kxm hub start<br/>kxm hub bind"] -->|"persist credentials and URL"| S
```

Values that `kxm hub start` and `kxm hub bind` persist on the machine (`hub-env.json` and `hub-binding.json` under the [state root](#state-outside-the-project)) apply only when the matching variable, such as `KXM_AUTH_TOKEN` or `KXM_SERVER_URL`, is unset. [Environment variables and limits](configuration.md#where-settings-come-from) lists every variable.

## `.kxm/modes.yaml` (`kxm.modes.v1`)

Modes for `kxm explain`, which estimates the prompt footprint and token cost of
a mode before you run it. Parsed with the general YAML parser; a file that fails
to parse, lacks `schema: kxm.modes.v1`, or lacks `majorModes` is silently
replaced by the built-in modes (`coder`, `planner`, `auditor`, `browser` and
domains `git`, `k8s`, `database`, `browser`). Your modes and domains are merged
over the built-in ones by name.

| Field | Type | Notes |
|---|---|---|
| `schema` | `kxm.modes.v1` | Required |
| `majorModes.<name>.baseTools` | Array of strings | Required per mode |
| `majorModes.<name>.description` | String | |
| `majorModes.<name>.contextFiles` | Array of repository-relative paths | Counted into the footprint |
| `majorModes.<name>.thinkingLevel` | `low`, `medium`, `high`, or `xhigh` | |
| `majorModes.<name>.model` | String | Default model for the estimate; `kxm explain --model` overrides it |
| `domains.<name>.tools` | Array of strings | Required per domain |
| `domains.<name>.description`, `.contextFiles`, `.promptSnippet` | String, array, string | |

```yaml
schema: kxm.modes.v1
majorModes:
  reviewer:
    description: Read-only review of a diff
    baseTools: [read, grep]
    contextFiles: [AGENTS.md]
    thinkingLevel: high
    model: claude/fable
domains:
  payments:
    description: Payments domain rules
    tools: [sqlite_query]
    contextFiles: [docs/payments.md]
    promptSnippet: Amounts are integer cents; never use floats.
```

Validated with `kxm explain --mode reviewer --domains payments,git --json`.

## `.kxm/template-provenance.yaml` (`kxm.template-provenance.v1`)

Written only by `kxm init`. It records exact-byte hashes and authority hashes
of the files the built-in template created, so later `kxm init` runs can apply
conflict-free template updates. Never edit it. Deleting it keeps the project
valid but makes template repair planning-only.

| Field | Type | Notes |
|---|---|---|
| `schema` | `kxm.template-provenance.v1` | |
| `templateId` | `builtin-minimal` | |
| `templateRevision` | `sha256:<hex>` | Hash of the canonical `files` list |
| `inputs.projectId` | Opaque ID | Must equal `project.yaml` `id` (`template_provenance_project_mismatch`) |
| `inputs.projectName` | String, 1 to 120 characters, one line | The name used when the template was rendered |
| `files[].path` | `.kxm/...` portable path | Strict code-unit order (`template_provenance_order_invalid`), unique after case folding (`template_provenance_path_collision`), never the provenance file itself (`template_provenance_path_invalid`) |
| `files[].sha256` | `sha256:<hex>` | Exact bytes as written |
| `files[].authoritySha256` | `sha256:<hex>` | Canonical JSON with prose fields removed: project `name`, repository `description`, agent `purpose`, workflow `description` |
| `files[].bytes` | Integer, 1 to 262,144 | Total at most 8 MiB (`template_provenance_bounds_exceeded`) |

The whole file must equal what one of the built-in template variants (`v1`,
`v2`, `v3-policy`, `v4-registry`) would produce for its `inputs`; otherwise
`template_provenance_revision_invalid`. It must be a regular file under
regular directories (`resource_not_file`, `resource_parent_symlink`).

Commands: `kxm init` creates it last in a create transaction and reads it in
validate and repair modes. The transaction directory `.kxm-init-transaction/`
at the Git root holds interrupted create or repair state; do not commit it.

## Tasks and goals (`kxm.task.v1`, `kxm.goal.v1`)

Work records under `.kxm/tasks/<id>.yaml` and `.kxm/goals/<id>.yaml`, parsed
with the general YAML parser by `plugins/kxm/src/task-manager.ts`. A file that
does not parse or lacks the right `schema` is skipped silently. The commands
create them; editing by hand is supported.

| Task field | Type and allowed values | Notes |
|---|---|---|
| `schema` | `kxm.task.v1` | |
| `id` | `task_` plus 12 hex characters | Also the filename |
| `goalId` | Goal ID | Optional; not checked |
| `title`, `objective` | String | `kxm task run` passes `objective` as the run prompt |
| `acceptanceCriteria` | `[{id, description, required}]` | No command writes it; edit by hand |
| `status` | `todo`, `in_progress`, `blocked`, `in_review`, or `done` | |
| `assignedWorkflow` | Workflow ID | Not checked at creation; `kxm task run` uses it, or `default` |
| `workflowRunId` | Run ID | Optional |
| `trackerSync` | `{tracker, issueKey, syncStatus, lastSyncedAt}` | `tracker` is `github`, `jira`, `gitlab`, or `none`; `syncStatus` is `synced`, `pending`, or `failed` |
| `createdAt`, `updatedAt` | ISO timestamps | |

| Goal field | Type and allowed values | Notes |
|---|---|---|
| `schema` | `kxm.goal.v1` | |
| `id` | `goal_` plus 12 hex characters | Also the filename |
| `title` | String | |
| `area` | String | Default `software-engineering` |
| `status` | `active`, `achieved`, or `abandoned` | |
| `successMetrics` | Array of strings | |
| `targetDate` | String | Optional |
| `createdAt`, `updatedAt` | ISO timestamps | |

```yaml
schema: kxm.task.v1
id: task_5644e7e51277
title: Add refund endpoint
objective: Expose POST /refunds with idempotency keys
acceptanceCriteria: []
status: todo
assignedWorkflow: review
trackerSync:
  tracker: github
  issueKey: "42"
  syncStatus: pending
createdAt: 2026-09-23T14:02:04.574Z
updatedAt: 2026-09-23T14:02:04.574Z
```

Written by `kxm task create "Add refund endpoint" --objective "…" --workflow
review --tracker github --issue 42`.

Commands: `kxm goal create|list`; `kxm task create|list|get|run|sync`.
`kxm task run` calls `kxm run <assignedWorkflow or default> <objective>` and
sets `status: in_progress` on success. `kxm task sync` marks the record
`synced` locally; it makes no tracker API call.

## Memory records (`kxm.memory.v1`)

Harness-agnostic project memory as Markdown files with YAML front matter.
Parser: `parseMemoryRecord` in `plugins/kxm/src/memory.ts`.

- `.kxm/memory/*.md` (top level only) are authored facts. Only
  `lifecycle: active` facts appear in `kxm memory brief` and in the projection
  that `kxm memory sync` writes into whichever of `AGENTS.md`, `CLAUDE.md`, and
  `GEMINI.md` already exist; it never creates them.
- `.kxm/memory/candidates/*.md` are candidates written by `kxm memory note`.
  Promote one by moving it into `.kxm/memory/` in a reviewed change.
- Every file under `.kxm/memory/` except `candidates/` is hashed into the
  memory revision pinned on each run.

| Field | Type and allowed values | Required |
|---|---|---|
| `schema` | `kxm.memory.v1` | Yes |
| `id` | Identifier starting with a letter, with single `-` or `_` between letter and digit runs; case-insensitive | Yes |
| `scope` | `agent`, `project`, `run`, or `operator` | Yes |
| `kind` | Non-empty string, for example `decision`, `architecture`, `convention`, `policy`, `learning` | Yes |
| `summary` | Non-empty string; secrets are redacted | Yes |
| `provenance.sourceType` | Non-empty string | Yes |
| `provenance.sourceRef`, `provenance.runId`, `provenance.timestamp` | String | No |
| `authority` | `instruction`, `evidence`, or `promoted` | Yes |
| `confidence` | `verified`, `probable`, or `uncertain` | Yes |
| `lifecycle` | `active`, `deprecated`, or `superseded` | Yes |
| `evidenceRefs` | Array (values are converted to strings) | No |

Front matter may not contain control-plane fields: `permissions`, `tools`,
`allow`, `deny`, `grants`, `approval`, `policy`, `scopes`, `credentials`,
`secrets`, `token`, `apiKey`, or `password`. The text after the front matter is
the body. One malformed authored file makes `kxm memory brief` and
`kxm memory sync` fail. The Claude Code plugin's session-start hook still
runs; it leaves out the memory brief and keeps the status sections.

```markdown
---
schema: kxm.memory.v1
id: gate-before-review
scope: project              # agent | project | run | operator
kind: convention
summary: Run the test gate before asking critics to review a diff.
provenance:
  sourceType: review
  sourceRef: docs/workflow-guide.md
  timestamp: 2026-09-20T12:00:00Z
authority: promoted         # instruction | evidence | promoted
confidence: verified        # verified | probable | uncertain
lifecycle: active           # active | deprecated | superseded
evidenceRefs:
  - run_01JEXAMPLE000000
---

Critics reviewed several diffs that later failed `npm test`. Gating first saves a review round.
```

Validated with `kxm memory brief --json`.

## Skills and improvement candidates

Both directories are written by commands, not configured by hand.

- `.kxm/skills/` holds the governed skill lifecycle:
  `candidates/<id>/`, `promoted/<id>/`, `quarantineds/<id>/` (the directory name
  the code uses for the `quarantined` state), and
  `rejected/<id>/`, each with `SKILL.md` and `metadata.json`
  (`kxm.skill-candidate.v1`), plus `history/<id>.jsonl`. Written by
  `kxm skills create|evaluate|promote|reject`; `kxm skills verify` detects
  out-of-band edits to promoted skills. Promoted skills are hashed into the
  memory revision pinned on each run. See [Skills](../guides/governed-skills.md).
- `.kxm/candidates/` holds improvement candidates (`<id>.json`,
  `kxm.candidate.v1`, with a proposed diff file) written by `kxm improve`
  (`--out-dir` relocates them; `--dry-run` writes none). The report itself goes to
  `<workspace>/assets/improvements/`. A candidate is a proposal: its diff has
  placeholder hunks, nothing applies it, and its promotion readiness never
  authorizes. See [Continuous improvement](../guides/continuous-improvement.md#coded-repeats-kxm-improve).

## Webhook workflow definitions

The hub's signed-webhook workflows are a JSON array, not YAML and not a
`kxm.workflow.v1` file. Supply it through exactly one of
`KXM_WEBHOOK_WORKFLOWS` (inline JSON) or `KXM_WEBHOOK_WORKFLOWS_FILE` (a path).
Parser: `parseWorkflowDefinitions` in `plugins/kxm/src/workflow.ts`; the hub
reads it at startup (`plugins/kxm/src/server.ts`).

Do not store the file under `.kxm/config/workflows/`: any `*.json` there is
treated as legacy configuration and makes the whole project unloadable
(`legacy_state_unsupported`). Do not point it at `.kxm/workflows/*.yaml`
either; those are `kxm.workflow.v1` files and fail to parse as JSON. A path
such as `.kxm/assets/webhooks/workflows.json` works.

[Workflow definitions](workflow-definitions.md#webhook-workflow-definitions)
documents every definition, stage and evidence-policy field, the transition
rules, the limits, and a validated example. Check a file with
`kxm gate validate --file <path>` (exit 2 when no source or both variables are
set); the hub loads it on `kxm hub start`, and `kxm workflow start`,
`kxm gate signal` and `kxm gate github watch` resolve each definition's secret
variables. See [Webhook workflows](../guides/webhook-workflows.md) for a
walk-through.

## Claude Code plugin settings

The Claude Code plugin manifest `plugins/kxm/.claude-plugin/plugin.json`
declares `userConfig` fields that Claude Code asks each user for. The plugin's
`.mcp.json` passes them to the KXM MCP server as environment variables.

| `userConfig` field | Environment variable | Required, default | Notes |
|---|---|---|---|
| `server_url` | `KXM_SERVER_URL` | Required, `http://127.0.0.1:7331` | The MCP server also falls back to that URL when empty |
| `auth_token` | `KXM_AUTH_TOKEN` | Optional; marked sensitive | Use the project token, never the admin token. See below for the fallback |
| `agent_name` | `KXM_AGENT_NAME` | Required, `claude` | When empty, `claude-<pid>`. If another live session already holds the name, the server registers once more as `<name>-<pid>` |
| `agent_purpose` | `KXM_AGENT_PURPOSE` | Required, `Claude Code implementation and review agent` | |
| `project` | `KXM_PROJECT` | Optional | When empty, the `name` in `package.json` at the project directory, else the directory name |
| (not a user field) | `KXM_PROJECT_DIR` | Set from `${CLAUDE_PROJECT_DIR}` | Used to derive the default project |

When `auth_token` is empty, the MCP server uses only this project's saved
project token from the hub credential file (`hub-env.json`) and never falls
back to the admin token. With neither, tool calls fail with a message naming
the fix.

The manifest also registers one `SessionStart` hook,
`node ${CLAUDE_PLUGIN_ROOT}/dist/claude-hook.js session-start`, with a 5-second
timeout. It runs only inside a KXM project, reads this project's state only,
never mints a token or writes a file, and always exits 0. See the
[plugin README](../../plugins/kxm/README.md) for what it adds to the session.

## Updater settings (`kxm.update.v1`)

`update.yaml` under the user state root (see the next section) controls
`kxm update`. A project `.kxm/update.yaml` is ignored with a warning. Parser:
`loadKxmUpdateConfig` in `plugins/kxm/src/kxm-update-config.ts`.

| Field | Type and allowed values | Required, default |
|---|---|---|
| `schema` | `kxm.update.v1` | Required |
| `auto` | Boolean | Required |
| `source` | `github` or `npm` | Optional, `npm` |

Unknown fields are refused (`update.yaml unknown field <name>`).

```yaml
schema: kxm.update.v1
auto: false        # required boolean
source: npm        # npm (default) | github
```

## Workspace layout: tracked, ignored, and state

Everything under `.kxm/` at the project root falls into one of three groups.
`kxm init` writes none of the ignore rules, so add them yourself.

| Path | Group | Written by |
|---|---|---|
| `project.yaml`, `repo/`, `project/env.yaml`, `agents/`, `models/*.yaml` (except `inventory.yaml`), `workflows/`, `gates.yaml`, `template-provenance.yaml` | Tracked configuration (the bundle) | You and `kxm init` |
| `roles/`, `routes.yaml`, `prices.yaml`, `modes.yaml`, `the role files` | Tracked configuration outside the bundle | You and their commands |
| `config.yaml` | Tracked if the project wants shared preferences; otherwise ignore it | `kxm config set` |
| `memory/`, `skills/`, `candidates/`, `goals/` | Tracked durable records | Their commands |
| `models/inventory.yaml` | Generated; track it if you want a reviewed snapshot | `kxm models inventory-refresh` |
| `assets/` | Tracked intentionally; `assets/generated/` is ignored | Workflows, `kxm improve`, `kxm session start` |
| `tasks/` | Ignored in the KXM repository; your choice | `kxm task` |
| `logs/` | Ignored runtime logs | The hub and workers |
| `state/` | Ignored restart state: the hub database `kxm.db`, Pi sessions, worker manifests | The hub and workers |
| `run/` | Ignored sockets (`run/ssh-sockets/`) | `kxm ssh` |
| `backups/` | Ignored; each `backup-<time>/` holds copies of the hub database, this project's Runtime event store and prompt sidecar (every project's, and the registry, with `--all-projects`), and `manifest.json` | `kxm backup` (without `--out`) |
| `config/` | Legacy: its JSON files make the project unloadable | Nothing current |
| `.kxm-init-transaction/` (sibling of `.kxm/` at the Git root) | Ignored; interrupted `kxm init` state | `kxm init` |

Recommended ignore rules for a project, based on the ones the KXM repository uses plus `.kxm/backups/`:

```text
.kxm/logs/*
.kxm/state/*
.kxm/tasks/
.kxm/run/
.kxm/backups/
.kxm/assets/generated/
.kxm-init-transaction/
*.db
*.db-shm
*.db-wal
```

`KXM_WORKSPACE_DIR`, `KXM_LOGS_DIR`, `KXM_ASSETS_DIR`, `KXM_STATE_DIR`, and
related variables move `logs/`, `assets/`, and `state/`. They do not move the
configuration files, which always live under `<project root>/.kxm/`. See
[Workspace directories](configuration.md#workspace-directories) and
[Backup and restore](../operations/backup-and-restore.md).

### State outside the project

The **user state root** is `KXM_STATE_HOME` when set (it must be absolute;
a relative value fails with `local_state_root_not_absolute`). Otherwise it is
`~/Library/Application Support/KXM` on macOS, `%LOCALAPPDATA%\KXM` on Windows,
and `$XDG_STATE_HOME/kxm` (default `~/.local/state/kxm`) on Linux.

| Path under the state root | Contents |
|---|---|
| `runtime/registry.db` | Runtime registry: projects, their control roots, and the supervisor claim |
| `runtime/projects/<key>/run-events.db` | Event-sourced runs for one project; `<key>` is the first 24 hex characters of the SHA-256 of the canonical project root path |
| `runtime/supervisor.token`, `runtime/logs/kxm-runtime.jsonl` | Supervisor credential and log |
| `projects/<hash>/repository-bindings.json` | Host-local member bindings from `kxm init --repository` (`kxm.local-repository-bindings.v1`) |
| `hub-env.json`, `hub-binding.json` | Persisted hub credentials and the machine's hub binding |
| `update.yaml` | Updater settings |

The **user configuration directory** is `KXM_USER_CONFIG_DIR`, default
`~/.config/kxm`. It holds `config.yaml`, global `roles/` and `workflows/`,
`session.token`, and shell completion scripts. Global
workflows are listed by `kxm workflow definitions` but never loaded by
`kxm run`.

## Worked example: a minimal two-step project

A project with one agent step and one command gate. Every file below passed
`kxm init --json`, `kxm run --dry-run`, `kxm trust check`, and a simulated
drive that finished `completed`.

Directory layout (inside a Git repository):

```text
.
├── package.json
└── .kxm/
    ├── project.yaml
    ├── repo/repo.yaml
    ├── agents/planner.yaml
    ├── agents/writer.yaml
    ├── gates.yaml
    ├── routes.yaml
    └── workflows/default.yaml
```

`.kxm/project.yaml`:

```yaml
schema: kxm.project.v1
id: prj_01JMINIMAL000000000000000
name: Minimal Example
defaultWorkflow: default
defaultExecutor: local
defaultHarness: pi
repositories:
  - id: control
    role: control
    required: true
    pathHint: .
```

`.kxm/repo/repo.yaml`:

```yaml
schema: kxm.repository.v1
projectId: prj_01JMINIMAL000000000000000
repositoryId: control
defaultAccess: write
```

`.kxm/agents/planner.yaml` (every workflow needs its coordinator agent):

```yaml
schema: kxm.agent.v1
purpose: Coordinate the pinned workflow.
role: planner
aliases: [coordinator]
tools:
  preset: coordinator
defaultRepositoryAccess: read
repositories:
  control: read
network: provider-only
resultSchema: kxm.assignment-result.v1
```

`.kxm/agents/writer.yaml`:

```yaml
schema: kxm.agent.v1
purpose: Implement the requested change in the control repository.
role: writer
aliases: [implementer]
tools:
  preset: workspace-writer
defaultRepositoryAccess: none
repositories:
  control: write
network: provider-only
resultSchema: kxm.assignment-result.v1
```

`.kxm/gates.yaml`:

```yaml
schema: kxm.gate-registry.v1
gates:
  test:
    kind: command
    argv: [npm, test]
    timeoutMs: 600000
```

`.kxm/workflows/default.yaml`:

```yaml
schema: kxm.workflow.v1
description: Implement a change, then run the test suite.
coordinator: planner
limits:
  maxTransitions: 4
steps:
  - id: writer
    kind: agent
    agent: writer
    aliases: [implement]
    maxAttempts: 2
    repositories:
      control: write
    requiredEvidence:
      - key: diff
        kind: artifact
    on:
      passed: verify
      failed:
        target: $terminal
        terminalStatus: failed

  - id: verify
    kind: gate
    gate: test
    maxAttempts: 2
    repositories:
      control: write
    requiredEvidence:
      - key: tests
        kind: gate
    on:
      passed:
        target: $terminal
        terminalStatus: completed
      implementation-failure:
        target: writer
        maxTransitions: 2
```

`.kxm/routes.yaml` (needed only for live drives):

```yaml
schema: kxm.routes.v2
updatedAt: '2026-09-23T00:00:00.000Z'
admitted:
  - xai/grok-4.6
disabled: []
```

Why each piece is there:

- `writer` routes a failure straight to a terminal `failed` status and
  declares `failed`, the outcome the producer falls back to.
- `verify` declares `implementation-failure`, the outcome a failing command
  produces. Routing that edge on `failed` instead is refused when the project
  loads (`gate_outcome_impossible`). The edge back to `writer` makes it a
  back-edge, so it carries `maxTransitions: 2` and the workflow carries
  `limits.maxTransitions`.
- The gate step lists `control: write`; the Runtime refuses command gate steps
  without a writable repository.
- The workflow omits `limits.maxAgentTimeMs`; with it, the Runtime refuses to
  drive the run.
- `kxm init` writes `.kxm/roles/writer.yaml`. The role file is the writer
  roster. An agent model is not checked against it.

Validate and run it:

```bash
kxm init --json                              # "action": "validated", "issues": []
kxm run default "Add a health endpoint" --dry-run --json
git add .kxm package.json && git commit -m "Add KXM project"
kxm trust check                              # exit 0: no permission expansion
kxm run default "Add a health endpoint"      # starts the Runtime and prints a run ID
kxm runs drive <runId> --simulated --wait    # model-free drive; runs npm test for real
```

The simulated drive records `passed` for the agent step, runs `npm test` in the
project root, and settles `completed` when it exits 0. When `npm test` fails,
the run returns to `writer` and fails with `budget_step_attempts` once
`writer` has used its two attempts. A live drive of this workflow is refused
today, because the `writer` step has write access (see
[Steps the Runtime does not execute yet](#steps-the-runtime-does-not-execute-yet)).
Stop the Runtime afterwards with `kxm runtime stop`.

In a project that `kxm init` created,
`kxm workflow add <id> --template implement-and-verify` writes a workflow of the
same shape (without the `requiredEvidence` entries), and `kxm run` prints the
matching `kxm runs drive <runId> --simulated --wait` command for each run it
creates.

## Related

- [Environment variables and limits](configuration.md): hub, agent and worker settings
- [Workflow definitions](workflow-definitions.md): webhook JSON and Runtime YAML side by side
- [CLI reference](cli-reference.md): the commands that read and write these files
- [Harness routing](harness-routing.md): choose and confirm a model route