CLI Reference¶
Complete reference for the keryx command-line interface. keryx
manages a per-project .metaproject/ workspace: it scaffolds the workspace,
keeps managed "service" files in sync (never touching your data/ artifacts),
and exposes feature commands for graph/wiki context, skills, code health, testing,
memory, agent orientation, managed work and review lifecycles, and security.
Global usage¶
| Global flag | Alias | Effect |
|---|---|---|
--help |
-h |
Print the top-level usage block. Also works (per subcommand) as keryx <command> --help. |
--version |
-v |
Print the installed version and exit. |
Running keryx with no command, or with --help/-h, prints the usage
block. An unknown top-level command (flow 353, AC3) prints exactly one line
to stderr — Unknown command: <x>. Did you mean: <up to 3 close matches>?
Run \keryx --help` for the list., the "Did you mean" clause omitted when
nothing is within edit distance 2 — and exits with code1`. It never prints
the full usage block; that used to go to stdout on this same error path.
An unknown SUBCOMMAND of a group with a verified subcommand vocabulary
(flow 353 review round 1) gets the identical one-line treatment, but naming
that group's own help — keryx health rn → Unknown command: rn. Did you
mean: run? Run \keryx health --help` for the list.Covers roughly forty
groups (modules,providers,wiki,health,memory,flow,mcp,skills, … — seesrc/lib/group-subcommands.tsfor the exact, hand-verified
list and why it is not simply "everyCLI_ROUTES` verb"); a group not on
that list, or a first argument that is a genuine positional (a path, an id,
a free-text prompt) rather than a closed subcommand vocabulary, is
unaffected and still reaches the handler's own usage on an unrecognised
value.
version¶
Check the installed Keryx version against the fixed npm registry endpoint.
The command uses the same advisory service as keryx shell. Human output shows
the exact upgrade command only when npm reports a strictly newer validated
version; --json returns the typed result for agents. A successful result is
cached for 24 hours, failed checks are suppressed for 15 minutes, and the
request timeout is 2 seconds. Unavailable, offline, timeout, and malformed
registry results are operational outcomes and exit successfully; they must not
block project work. Keryx never auto-installs an update. If an update is
available, run:
A pre-feature installation cannot discover the first release containing this
check through code it does not yet have; if version check is unknown there,
continue work and update through an external or manual channel. A project's
generated index gains the guidance only after keryx init, keryx update, or
rules sync regenerates it. That index text is prompt guidance, not enforcement.
Top-level commands¶
| Command | Purpose |
|---|---|
help |
Grouped command help by task — keryx help [group|command]. |
doctor |
One-page health check with a fix hint per line; --json for {checks:[...]}. |
shell |
Start the interactive TUI agent shell (sessions are per-project). |
version |
Check whether the installed Keryx version has a newer npm release. |
sessions |
List, fork, export, or locate agent sessions for the current project. |
bus |
Agent bus across this clone's worktrees: list peers and leases, read the log, send a message, prune. |
harness |
Drive the agent execution loop non-interactively (run, exec, extension, wave, replay). |
init |
Initialize .metaproject/ in the current project. |
status |
Show local Metaproject status. |
modules |
View, enable, or disable workspace modules. |
projects |
Manage the user-global project registry that remote entry addresses projects by. |
serve |
Loopback-bound HTTP entry over the agent harness (opt-in, off by default). |
metrics |
Collect, validate, and report execution-observability metrics. |
update |
Refresh managed service files without touching data artifacts. |
sync |
Reconcile graph, wiki and memory with the current code; optional git hooks keep them in step. |
commands |
The agent-facing command registry: descriptors, and natural-language intent resolution. |
dashboard / dash |
Build or open the project admin dashboard. |
gdgraph |
Build and query the code dependency graph. |
ctx |
Run compact, token-aware context commands and save raw output. |
wiki |
Manage the local project knowledge base. |
skills |
Manage bundled and project working skills. |
skill-verify-skill |
Alias for skills verify. |
health |
Aggregate code-quality signals and run the quality gate. |
test |
Analyze testing context and normalize test reports. |
memory |
Store and search long-term project memory. |
flow |
Agent-first work lifecycle (Task Manager). |
product |
The product's intent as a derived index (index), and intents closed in code that nobody looked back at (open). |
review |
Create and complete durable managed review packages. |
rules |
Sync/distill root AGENTS.md/CLAUDE.md into project rules. |
standard |
Validate the workspace against the Metaproject Standard and report capabilities. |
agents |
Manage the optional global Metaproject bootstrap for agent runtimes. |
orient |
Emit or install bounded Metaproject + graph + wiki startup context. |
security |
Policy-based scanning, redaction, guardrails, and audit reports for agent input/output and artifacts. |
mcp |
Expose Metaproject services over the Model Context Protocol (opt-in, off by default). SAC tools are stdio-only. |
acp |
Serve the harness to an editor over the Agent Client Protocol (newline-delimited JSON-RPC on stdio). |
workspace |
Shared Agent Context: create/list/show workspaces, FWK overview/read, propose/review, collaboration overview, policy-readiness. Not listed by keryx commands. |
Optional dependencies and graceful degradation¶
Several commands ship as opt-in capabilities that lean on an optional dependency
(e.g. a tree-sitter grammar, an embedding/model backend) or a pulled asset. When
that dependency or asset is absent, the command degrades gracefully: it warns
once, falls back to the deterministic built-in path, and still exits 0 (for
example memory index --embeddings builds the lexical index only, and
security eval --with-model silently uses the pure detector path). The single
sanctioned exception is mcp serve, which hard-fails with an actionable
message when the optional MCP SDK is not installed.
help¶
Grouped command help by task, added by flow 303. Separate from --help/-h
and a bare keryx, which keep printing the flat usage block above unchanged.
keryx help(no argument) prints every command group, in onboarding order (Start here; Connect a model provider; Look and feel; Working in keryx shell; Project knowledge; Managed work; Automation; External agents, ACP and MCP; Maintenance and diagnostics), each with its commands and a one-line summary.keryx help <group>— a group slug (start-here,connect,look-and-feel,shell-work,project-knowledge,managed-work,automation,external-agents,maintenance) — prints just that group.keryx help <command>prints that command's full usage: the same rich helpkeryx <command> --helpprints today (flow,trigger,serve-mcpandgovernancekeep their own richer help; every other verb gets itsUSAGE_BODYusage block).keryx help <slash-command>(e.g.keryx help /theme) explains akeryx shellcommand, since those have no standalone CLI form.- An unknown group, command or slash-command name exits non-zero and suggests the closest matches by edit distance.
In the OpenTUI shell, /help opens a tabbed modal built on the same grouped
table — one tab per group, arrow keys to move, Enter for a command's detail,
Esc to close. The readline shell, --no-tui, and the ACP host print the
same grouping as text, since a modal cannot render there. On a brand-new
keryx shell with no model provider configured yet, the modal opens once,
already on the "Connect a model provider" tab.
doctor¶
One page: is this environment set up correctly. Added by flow 353 (P0 W5).
Every check reports ok, warn or fail, with a fix hint on anything that
is not ok:
- version — keryx's own version, and whether a newer release is
available (
keryx version check's own cache/backoff; up to a 2s network call the first time). - bun — the running Bun version against the floor
package.json'sengines.bundeclares. - ripgrep — is
rgonPATH(required bykeryx ctx rgand the agent'ssearch_codetool). - sandbox — is the OS sandbox launcher (bubblewrap on Linux, Seatbelt on
macOS) installed (
keryx sandbox status's own report). - providers — which providers have a credential configured, by name only — never a value.
- mcp — config validity and trust/approval state for the MCP servers
keryx connects to. This does not dial any server (unlike
keryx mcp doctor, which does, and is named in the fix hint when a deeper check is needed) — connecting to every configured server, some of which are real network/subprocess operations with a 10-second default timeout each, would make this command's own 3-second budget impossible to hold on a project with several servers configured. - integrations — drift in any installed editor/agent integration
(
keryx integrations doctor --runtime all). - standard — Metaproject Standard warnings and errors
(
keryx standard doctor). - entrypoints — where the managed
keryx:indexblock, the managed ignore rules and the keryx-managed Claude hooks are, against whereagentEntrypointssays they belong: an uncommitted copy in a file whose scope is local, a block still inCLAUDE.local.mdor a keryx-generatedAGENTS.override.mdafter the runtime went back toshared, a stale or oversized override, a local target missing in this checkout or not ignored. Warnings only; the fix iskeryx update. Content the team committed inHEADis the shared case and is not warned about. - worktrees —
.claude/worktrees/<name>entries (the agent harness's own transient per-agent scratch checkouts) that git no longer recognises as a real worktree — reported, never deleted. - graph-freshness / wiki-freshness — read from the last
keryx gdgraph build/keryx wiki freshnessreport, never recomputed here (recomputing either inside a command meant to run in under three seconds would make the budget impossible to hold, and would couple this command to two subsystems it otherwise only reads a file from).
Exits 0 unless at least one check is fail. --json emits
{checks:[{id,status,detail,fix?}]}.
In keryx shell (readline and the TUI), /doctor prints the identical
report inside the session — the same builder, never a second
implementation of any one check.
setup¶
Print the Metaproject preparation guide. Read-only: it does not run init,
update, sync, or wiki enrichment.
| Argument | What it prints |
|---|---|
| (none) | All three scenarios. |
init |
From scratch: scaffold, graph, health, wiki drafts, memory index, gates. |
refresh |
After a pull: refresh service files and rebuild only stale layers. |
repair |
Partial or stale: read standard doctor first, then idempotent rebuild. |
Each scenario shows the CLI lines and a prompt to paste to a coding agent. An unknown argument, or more than one, exits non-zero and names the three choices.
In keryx shell, /setup opens the same guide. The TUI shows one tab per
scenario; readline prints the text. /setup init opens that scenario, and an
unknown or extra argument prints the same error instead of the guide.
Neither form runs the commands.
shell¶
Start the interactive agent shell. This is the TUI agent harness; bare keryx
prints CLI usage and does not start it. Sessions are per-project.
keryx shell [-c|--continue] [-r|--resume [id]] [--fork|--take-over]
[--provider <p>] [--model <m>] [--base-url <url>] [--agent|--chat]
[--tui|--no-tui] [--debug] [--name <name>] [--guard]
| Flag | Description |
|---|---|
| (default) | Full-screen OpenTUI renderer plus the agent, whenever stdout is a TTY. |
-c, --continue |
Resume the most recent session for this project that no other shell has open. When it skips a newer session, it prints that session and its holder. |
-r, --resume [id] |
Resume a specific session; without an id, pick one interactively. Without a TTY, bare -r uses the latest session no other shell has open. |
--fork |
With -r <id>: fork a session another shell has open, and continue in the fork. |
--take-over |
With -r <id>: take over a session whose holder is stale — on this host, process alive, but no longer heartbeating. Refused while the holder is live. |
--provider <p>, --model <m> |
Skip the provider/model picker. |
--base-url <url> |
Point the provider at a custom endpoint. |
--agent / --chat |
Agent mode with tools, or chat without them. |
--tui / --no-tui |
Force the full-screen renderer, or fall back to the line-based readline shell. |
--debug |
Record the session to ~/.local/share/keryx/debug/<run>/ (shell.ndjson: terminal-input state, stdin calls with stacks, key names but never typed text, dialogs, tools, agent state) and start a watcher process (watcher.ndjson) that re-arms terminal input if the shell stops reading it. The newest run is named in debug/latest.txt. |
--name <name> |
Set this shell's bus name. Names follow D-06: lowercase letters, digits and -, up to 32 characters. all, cli and system are reserved. If a live shell already holds the name, the new shell gets <name>-2. |
--guard |
Turn on the turn guard for this session only (TUI, agent mode; off by default). /guard on turns it on for every future session too. |
--review-system <inherit\|review-auto> |
Who answers a review run's approval questions. inherit (default) follows the permission mode; review-auto answers them itself (reviewer spawns, ask_user menus) while destructive, credential, publish and hook-tightened calls still ask you. A call you refuse stays refused (same fingerprint, any spelling: "refused earlier: choose another path", no modal) until the shell restarts. Saying "сделай автоматическое ревью" in the session has the same effect. See Review system. |
The renderer falls back to readline gracefully when the TUI cannot start, and off a TTY the shell is non-interactive by default.
An interactive shell joins the agent bus at start, printing bus: joined as @<name> · <n> peers. Inbound messages appear as ⇄ [#<seq>] @from kind: preview — the sequence number and the message's short id (its first 8 characters). The /bus slash command offers list (show peers), send @x or @x text (send a message), ask, reply <#seq|id-prefix> <text>, name <new> (rename this shell), pause [@name|@all] [--scope turns|git-publish|advisory] [--ttl 30m] <reason…> (create a pause lease held by this shell; target and scope default to @all and turns), resume [leaseId] (end a lease this shell holds — the id defaults to its own), and override [leaseId] (release just this shell from a lease that targets it — the id defaults to whichever turns lease currently holds it). /bus reply resolves its first argument against the last 200 rendered events — a bare or #-prefixed sequence number, or a unique prefix (at least 8 characters) of a message's id — and always replies to that sender's underlying instance, so it still reaches them even if they renamed since; an argument matching no rendered message is refused with one line. The bus stays off when KERYX_BUS=off, shell config bus.enabled: false, or a CI environment is detected, printing bus: off (<reason>).
While a turns pause lease applies to this shell, an operator line at the readline prompt does not start a main-agent turn: it prints one line naming the holder, the reason and the remaining TTL, keeps the line, and runs it once the lease is resumed, overridden with /bus override, or expires. /bus and /exit still work while held.
When the bus is joined, peer messages also reach the agent as tool-provenance context. An idle agent is woken by a question, reply, handoff or name-addressed notice, with the wake capped by the same auto-wake limit as task notifications; readline shows bus: N message(s) pending and delivers them on the next turn. The agent has two tools: bus_list (read peer names and message log) and bus_send (send a message to a peer or broadcast).
One shell holds a session at a time. -r <id> on a session a live shell holds
offers fork, view or cancel in an interactive run, plus take over when the
holder is stale; a non-interactive run exits 1 with a --fork hint instead.
Invalid combinations are refused: --fork or --take-over without -r <id>,
both together, or either with -c.
If a TUI session stops reacting to the keyboard and mouse while it still draws
(spinner and timer running), run kill -USR2 <keryx pid> from another
terminal: every session re-arms its terminal input on SIGUSR2.
Turn guard (opt-in, off by default). After an agent turn ends with a final
message, the guard checks — advisory only, never blocking — whether the
request looks done and whether the message contradicts what the tools really
did (a claimed passing test suite when the last test run failed, a failure
never mentioned). A likely-incomplete or contradicted turn prints one compact
notice line; /guard opens a modal with this session's history, each entry's
deterministic facts, Jev's probabilities when it was asked, and the reason.
Deterministic contradictions are caught even without a Jev credential. Turn on
for one session with --guard, or persist it with /guard on (/guard off
turns it back off); the setting lives in ShellConfig.turnGuard.enabled
(~/.local/share/keryx/auth.json). A turn with no tool calls and a short
reply to a short question is skipped without asking Jev. When the guard IS
asked (i.e. no deterministic contradiction already decided the turn and it
was not skipped as trivial), the turn's user request, the final assistant
reply, and the deterministic facts (tools called and failures, files
written/edited, commands run and their exit status, tests run and their
pass/fail counts) are sent — redacted through the same security service every
other Jev-backed review command uses — to Jev on OpenRouter.
Routing classifier (opt-in, off by default). Before a turn starts,
keryx shell can sort the request into a category (review, subagents,
quick, coding, planning, docs, unattended — the routing table's
catalogue, keryx routing list) and dispatch it to that category's
configured model instead of the session's own. A slash command always
bypasses classification; a short chit-chat message resolves quick and a
message naming a review resolves review without any model call. Otherwise
Jev (when a credential resolves) is tried first, then the session's own
model with a strict one-token label prompt, each stage bounded by a 3s
timeout — a timeout, error, or low-confidence answer falls back to the next
stage, and with nothing left, the turn simply runs on the session's own
model, exactly as before this feature existed. Jev's own pick is trusted
starting at confidence 0.45 (DEFAULT_JEV_CLASSIFIER_CONFIDENCE_THRESHOLD,
src/harness/decision/jev-classifier.ts) — measured against a real key:
100% (20/20) on the sample set used to tune it, and 95% (19/20) on a
separate, held-out set of 20 new requests never used to pick the threshold
(scripts/routing-classifier-live-check-holdout.ts).
The category is resolved through the same project → user → derived → default
precedence keryx routing list//routing use (keryx routing above). An
operator who has configured nothing still gets routed by the derived
layer: planning/review go to the strongest model available at or above
the session's own, subagents/docs/unattended go one size step down, and
quick goes to the smallest available model — so /route on changes
something even before any keryx routing set. An explicit /model switch
during the session always wins over classification for the rest of that
session. When a turn is routed, a small tag (e.g. [quick -> anthropic/
claude-haiku-4-5], naming the category, the resolved model, and which stage
decided it) prints under that turn, and the sidebar's Route row shows
whether routing is on and how many turns it has routed this session. Turn on
with /route on (/route off turns it back off; bare /route prints the
current state); the setting lives in
ShellConfig.routingClassifier.enabled (~/.local/share/keryx/auth.json).
sessions¶
Inspect the append-only agent sessions recorded for the current project.
| Subcommand | Description |
|---|---|
list |
Print the sessions recorded for this project, newest first. Forks are marked ↳. The LIVE column reads live when a shell has the session open, stale when its holder stopped heartbeating, and is blank when no shell holds it. --json prints the rows as JSON, each with a live field: "live", "stale" or null. A session that was driven from a Telegram topic has a ⇄ remote <topic>: <from> - <to> line under its row, one span per time /remote-control was on, and its JSON row carries "remote": { "name", "intervals" }; the keryx shell -r picker marks it ⇄ remote. |
fork <id> |
Branch a session: a new session with the same history and parentSessionId set to the original. --title "<t>" names it, --json prints the result as JSON. Writing to the fork never touches its source. |
export <id> |
Emit one session in full, for archiving or review. |
path |
Print the directory sessions are stored under. |
session is accepted as a singular alias. Sessions are per-project — isolated by
git root, or by absolute cwd outside a repository — so list never shows another
project's work. The harness page covers what a session
holds and what forking copies.
acp¶
keryx acp makes keryx speak the
Agent Client Protocol (ACP) v1: an editor
or another ACP client launches it as a subprocess and talks to it over
its stdin/stdout — newline-delimited JSON-RPC 2.0 in both directions, no
Content-Length framing (not LSP framing). It is not something a person runs
interactively; the client owns the process's lifetime and the conversation.
Provider and model. With no flags, keryx acp uses the provider and model
keryx shell would start with in the same place — it calls the shell's own
start-up resolution, not a copy of it: the selection keryx shell saved the
last time you picked one (in auth.json under keryx's config directory,
~/.local/share/keryx/ on Linux and macOS), with saved logins refreshed first
and your saved API keys applied the same way. --provider and --model
override that, and must be given together. The per-provider settings the shell
applies at launch come along through the same resolvers: saved modelParams
(temperature, maxOutputTokens, timeoutMs), the output-token budget, and
the saved reasoning effort (each still overridable by its environment
variable). What has no ACP equivalent is the shell's in-session state — a
/reasoning or /model change made inside a running shell applies to that
shell only. What keryx shell does beyond that — detecting
providers and asking you to pick — has no equivalent here, because there is
no one to ask over this wire; so run keryx shell once and pick, or put both
flags in the arguments your client launches keryx acp with. --base-url
overrides the endpoint, as in the shell; --data-dir overrides where sessions
are stored (mainly useful for a sandboxed client integration test).
Switching model from the editor. That launch model is where a session
starts, not where it has to stay. Every session/new and session/load
response carries configOptions with one select option of category
model — the editor's model picker — whose currentValue is the model the
session is running. Its values are <provider>/<model> for every model keryx
can run here, from the same source keryx shell's picker uses: the providers
it detects (with your saved API keys) and each one's model list, narrowed to
providers that have a usable credential — the shell's picker also lists
providers you have not configured yet so you can enter a key, and an editor
has nowhere to enter one. Each provider is reached at the endpoint the shell
would use (a per-provider endpoint you saved in the shell wins), and Ollama is
probed at --base-url only, as in the shell. The launch model is always in
the list. Building the list touches the network, so it is started at launch
and a new session waits for it at most 8 seconds: a list not ready by then is
not waited for — that session offers only the launch model, stderr says so,
and the next session asks again. If the list was still building, that session
is not left with one entry forever: once it arrives, keryx sends that
session's picker one config_option_update with the complete list (the model
already running stays selected) — nothing arrives if the list fails, or if
the session or connection has ended first. Choose one
in the editor (session/set_config_option, answered with the complete,
updated configOptions) or type /model <value> (a bare model id works when
only one provider has it; keryx then sends config_option_update). The
switch is per session, applies from the next turn — a turn already
running finishes on the model it started with — and builds the provider the
way the launch did: saved logins refreshed first, the shell's own provider
factory, the new provider's saved settings. A value that is not in the list,
or a provider that turns out to have no usable credential, is refused with
the reason and the session stays on its model; of two switches in flight,
the one requested later wins, and one that fails leaves the other in effect.
Nothing is saved: keryx shell's selection is left as it was.
A loaded session continues on the model it last ran — the session record
names it — when that model is still in the list here; otherwise it continues
on the launch model, and keryx says so in the session (keryx: this session
last ran …, but …; it continues on …). A session this connection has already
run keeps the model it has now.
When nothing is configured — no flags, nothing saved, or a provider with
no usable credential — keryx acp still starts and still answers
initialize, writes one line to stderr saying what is missing, and refuses
every session/new and session/load with a JSON-RPC error (-32600,
data.condition: "provider-not-configured") whose message names what to
configure and how. It never answers a turn with a stand-in: the offline test
provider is reachable only through the test-only --fixture flag, and
--provider fake is refused like any other missing provider.
Nothing but protocol frames ever reaches stdout — every diagnostic goes to stderr, which the client may capture, forward, or ignore; a stray log line on stdout would otherwise corrupt the stream.
Honesty note: this is verified against the published v1 JSON Schema and
against this repository's own scripted test client, driving a real
keryx acp subprocess over a real pipe (src/acp/*.process.test.ts). The
first shipping client driven against it by hand — Zed, right after 0.2.154 —
found two defects the scripted client could not (a test provider as the
default, and every session/new carrying MCP servers refused); both are
fixed. No shipping IDE is part of the automated tests — if you wire one up and
hit a mismatch, that is new information, not a contradiction of something
promised here.
Methods implemented¶
| Method | What it does |
|---|---|
initialize |
Negotiates the protocol version and returns keryx's agentCapabilities/agentInfo. Requesting the version keryx serves gets it back unchanged; requesting a newer one gets keryx's latest supported version — not an error, the spec requires this, and the client then decides whether to proceed or close the connection; a malformed or out-of-range version (not an integer, or outside uint16) is refused with a JSON-RPC error. Any other request before initialize succeeds is refused, not served. |
session/new |
Creates a keryx session bound to resolveProjectRoot(cwd) — the git toplevel above the requested cwd, or the requested cwd itself outside a repository. session/list/session/load report this resolved root back, not the cwd you sent, so a session opened at /repo/packages/web is later listed with cwd: /repo. mcpServers is accepted: its stdio entries are started (or, when another session on this connection already runs the same list, shared) and the others are reported — see MCP servers from the client. Only a list that does not match the schema (not an array, or an entry with no name) is refused, with -32602. Refused with the configured message when no provider is configured (see above). The response carries configOptions (the model option, see Switching model from the editor above), and right after it keryx sends one available_commands_update (see Slash commands). |
session/set_config_option |
Switches the session's model from its next turn (configId: "model", value one of the option's values) and answers with the complete configOptions. Not refused while a turn runs — that turn keeps its model. An unknown option id or value, or a provider that cannot be built, is refused with -32602 and the reason. |
session/prompt |
Runs a real harness turn in that session and streams session/update notifications (assistant text, reasoning, tool calls and their results) as the turn runs — not buffered to the end — resolving with the spec's stopReason (end_turn, max_tokens, max_turn_requests, or cancelled) once it finishes. One turn per session at a time: a second session/prompt for a session whose turn is still running is refused with -32600 and data.condition: "session-busy" rather than interleaved into the same transcript. Wait for the first turn's response before prompting again; session/cancel shortens that wait but does not end it, because the slot is freed by the cancelled turn itself once it observes the abort — a turn parked in a long tool call frees it a moment later, not instantly. |
session/cancel |
A notification (no reply). Aborts the running turn — the same abort path a local hard-stop uses — and settles any session/request_permission the turn had open as a local denial, so a pending ask never leaves the client hanging. The turn's session/prompt response resolves stopReason: "cancelled", and no further session/update for that turn is sent afterwards. Cancelling an unknown or already-finished session is a harmless no-op. |
session/list |
Lists the project's durable sessions — the same store keryx sessions and keryx shell --resume read. An omitted cwd lists the sessions of the ACP process's own project root (refusing an optional parameter would itself be a conformance break); a provided cwd filters to that project. One page per call; there is no pagination (nextCursor) today. |
session/load |
Loads a session created anywhere — including one created outside any ACP connection, such as with keryx shell — and replays its history as session/update notifications before responding, as the spec requires. user/assistant messages become user_message_chunk/agent_message_chunk; a stored tool call/result pair becomes a tool_call + tool_call_update sharing one id; system messages are dropped (there is no ACP chunk for them). A replayed tool call's status is always reported completed — the persisted transcript keeps only the final content, not a separate success/failure marker, so that is the honest approximation available, not a claim that the original call actually succeeded. Loading a session that has a turn in flight is refused the same way session/prompt is (-32600, data.condition: "session-busy"): the load would replace the history the running turn is still writing to. A session whose transcript exists but cannot be read is answered -32603 with the session id, the transcript's path and the reason in error.data — not resourceNotFound (the session does exist) and not an empty replay (which would tell the client the conversation had no messages). This is the one error on this wire that names a path on the agent's filesystem; it is the transcript under the data directory the client itself launched the agent with, and it is there because "which file, and why" is what makes the failure fixable. mcpServers is handled as for session/new, and the list a load carries replaces the session's servers: the session is rebound to the set for its new list, a set no session uses any more is stopped, and nothing is started if the load itself is refused. Like session/new, the response carries configOptions and is followed by one available_commands_update. |
Tools a session offers¶
A turn in an ACP session is offered:
- keryx's project tools —
search_code,graph_find,graph_query,graph_symbol,graph_path,graph_affected,repomap,memory_search,read_wiki,wiki_ask,wiki_resolve,wiki_evidence,wiki_backlinks,wiki_freshness,flow_status,health_status,skills_catalog,skill_loadandtest_related— in exactly the projects wherekeryx shelloffers them. They come from the same assembly the shell uses, gate included: in a project with no usable metaproject (no manifest and nothing built under.metaproject/) onlysearch_codeis offered, because every other one could only answer "never built here". All are read-only. get_cwd,list_dir,read_file(through the client'sfs/read_text_filewhen it advertises it, see below),shell_execandapply_patch— the last two always asked throughsession/request_permission.search_tool/use_tool, only when the client sent MCP servers (above).
Each is reported to the client with an ACP tool kind: the search tools as
search, the read tools as read, shell_exec and use_tool as execute,
apply_patch as edit — so an editor shows what kind of call it is looking
at rather than a generic one.
Not offered, on purpose: web_fetch and web_search, and keryx's own
configured MCP servers — their results are untrusted content, which switches
on an approve-before-announce path this wire has no live test for yet;
spawn_subagent, because delegation needs a process port the ACP server does
not build; the agent-bus tools, a shell surface; and ask_user, workspace,
Slate, execution-plan and background-task tools, which need a host or a
session store this wire does not carry. /status lists what a session is
actually offered.
Slash commands¶
After every session/new and session/load keryx sends one
available_commands_update listing the commands it handles over ACP, in the
published shape (name, description, and input.hint for one that takes an
argument). A command arrives as ordinary prompt text beginning with /;
keryx answers it itself — it never reaches the model and adds nothing to the
conversation's history.
What counts as a command: a prompt whose first block is ONE line of
text starting with / and a word — /model x, /status, but also /tmp is
full or /explain this, which are answered with the command list rather
than sent to the model. To send text like that to the model, start it with a
space (/explain this), as Zed itself suggests, or put it on more than one
line: a prompt with a second line is never a command, so nothing typed after
a command is silently dropped. A path (/src/cli.ts fails) is not a command
either. A command that takes no argument refuses extra text, and any command
sent with an attachment refuses it — in both cases saying so and doing
nothing, and never repeating the attachment back. session/cancel while
/model is building the new provider cancels the switch: the session keeps
its model.
| Command | What it does |
|---|---|
/help |
Lists these commands. |
/model [<model>] |
Without an argument, lists the models the session can run and marks the current one. With one, switches to it from the next turn — the same switch as the editor's model picker — and sends config_option_update. |
/reasoning [<level>] |
Shows or sets this session's reasoning effort (off, minimal, low, medium, high, xhigh, max) from the next turn. Not saved, as in the shell. |
/status |
The session id, project root, model, reasoning effort, the tools the session's turns are offered, and whether the client's MCP servers are running. |
Every other shell command is left out because it needs the terminal UI —
/workspace, /review, /governance, /triggers, /integrate, /mcp,
/game and the pickers — or
a session feature this wire does not carry. One typed anyway is answered with
the list above rather than sent to the model.
MCP servers from the client¶
An ACP client may send the MCP servers configured in its own settings with
every session/new and session/load — Zed does. keryx handles each entry on
its own:
- stdio entries (
{name, command, args, env: [{name, value}]}) are started with theircommand,argsandenv, through the same MCP client and dial procedurekeryx shelluses for its configured servers. The child gets the entry'senvon top of keryx's own environment, with keryx's credentials stripped from it — credential-shaped names, and every name saved in your config (auth.json), whether or not this run has actually loaded it; its stderr is discarded. The session is answered at once; its firstsession/promptwaits for the servers to finish starting. - One running set per distinct list. Zed sends its whole list with every
new thread; sessions on one connection that send the same list (same
entries, same project root) share one set of server processes rather than
starting a copy each. Threads therefore share each server process — a
stateful server (one holding an open browser, a login, a cache) shares that
state across them. When a thread binds to a running set, any server in it
known to be dead — it failed to start, or its process has exited — is
redialled, so a new thread recovers a broken server without restarting the
agent. A server that is merely slow (busy in another thread's long call) is
left alone: slowness is not taken as death. A server that hangs without
exiting is therefore not recovered until the agent restarts. A set
stops when no session uses it any more (a
session/loadrebinding the last one to a different list) or when the connection ends. Oncekeryx acphas begun shutting down, newsession/new,session/loadandsession/promptrequests are refused (-32600,data.condition: "shutting-down"). - Their tools reach the model the way they do in
keryx shell: throughsearch_tool(find a tool) anduse_tool(call one), offered only in a session that has client servers. Everyuse_toolcall is asked throughsession/request_permissionand runs only on an explicit allow; a denial ends it exactly as a local denial does. A server's output is treated as untrusted content, so a later gated call in the same turn is asked about too, even under a mode that would otherwise allow it. httpandsseentries are not started — keryx advertisesmcpCapabilities: {http: false, sse: false}— and a server that fails to start (a missing command, a failed handshake) does not fail the session. Either way the session is created, every other server still starts, and the client is told, per server, by name and with the reason: as an agent message in that session (keryx: MCP server "<name>" …), sent right after thesession/new/session/loadresponse — ACP has no dedicated channel for server status, and an agent message is the one every client shows — plus the same line on stderr.- Every server keryx started is stopped when the connection ends — stdin
closing, or
keryx acpreceiving SIGTERM or SIGINT (it then exits 143 or 130) — and when no session uses its set any more. keryx closes each server's stdin and escalates to SIGTERM, then SIGKILL, if it does not exit, within the same bounded gracekeryx shelluses. SIGKILL tokeryx acpcannot be handled: its servers run in keryx's process group, so a client that kills the group takes them down too; one that kills only keryx closes their stdin, which a conforming stdio server treats as the end — a server that ignores end of input outlives it. - Secrets. The
envvalues and header values in these entries are passed to the server process. What keryx itself writes is scrubbed of them — replaced with<redacted>— in these places: every text a client server produces throughsearch_toolanduse_tool(results, error messages and tool descriptions), matched both as written and in its JSON-escaped form (a value containing"or\appears escaped once serialised) and before the 20,000-byte result cap is applied, so a value the cap would cut in two is still removed; and every start-up failure reason reported to the client or stderr. keryx never logs an entry or writes it to the session record. The limits: a value shorter than 8 characters (DEBUG=1, a port) is not treated as a secret, since replacing every1in a message would make it unreadable and protect nothing; and a server that transforms its credential before returning it — base64 or otherwise re-encoded, split across fields, reversed — is not recognised. Anything the model then writes itself (for example, copying such a transformed value into a later tool call) is not scrubbed either.
Methods refused, and why¶
Every method ACP defines that keryx does not implement answers
-32601 Method not found with a data.reason explaining which of the
two situations it is: "keryx has not implemented this" or "your client
called something its own advertised capabilities say it shouldn't have". A
conformant client reads agentCapabilities on initialize and never calls
these; the refusal exists for the client that does anyway.
| Method | Refused because |
|---|---|
authenticate |
keryx advertises no authMethods; there is nothing to authenticate against. |
logout |
agentCapabilities.auth.logout is not advertised. |
session/resume |
sessionCapabilities.resume is not advertised; use session/load, which replays history instead. |
session/close |
sessionCapabilities.close is not advertised; keryx sessions are durable on disk and have no open/closed state to leave. |
session/delete |
sessionCapabilities.delete is not advertised; session retention is an operator decision, not a client one. |
session/set_mode |
session/new returns no modes, so there is no mode to set. (The model is a config option — see session/set_config_option above.) |
Client capabilities¶
On initialize, a client advertises clientCapabilities.fs (with
readTextFile/writeTextFile) and clientCapabilities.terminal. What keryx
does with each is a deliberate, per-capability decision, not a uniform rule:
- Reads. With
fs.readTextFile: trueadvertised, keryx'sread_filetool callsfs/read_text_fileon the client and returns whatever content the client answers with — not necessarily what is on disk, since the client's view may differ (an unsaved buffer, for instance). Without it, or with itfalse, keryx reads the file itself with no wire call at all; the turn completes the same way either way. This is the one half of AC6 that is fully capability-routed, and it is tested both ways over a real pipe: the frame is asserted present when advertised and absent — for the whole connection, not just the one call — when it is not. - Writes. keryx's
apply_patchtool always writes locally, whether or notfs.writeTextFileis advertised.apply_patchapplies a multi-file unified diff atomically (viagit apply) — all hunks across all files land, or none do — andfs/write_text_file's shape (one file, full content, no diff, no cross-file atomicity) cannot express that guarantee. Rerouting through it would mean giving up atomicity or reconstructing it with scratch-index plumbing outsidegit apply, which is a redesign ofapply_patch, not a capability branch.keryx acpnever callsfs/write_text_filein this release, with or without the capability being advertised. - Shell. keryx's
shell_exectool always runs locally too, regardless ofterminal. Its local implementation (background jobs, streaming output, timeouts, and the permission gate below) has no one-for-one match in ACP'sterminal/create+output+wait_for_exit+kill+releasesurface, and keryx does not need a remote terminal — the client and the agent already share a machine, exactly askeryx shelldoes.keryx acpcalls noterminal/*method in any capability configuration, tested both with and withoutterminaladvertised.
Permissions¶
A tool call the policy engine would ask a local operator about produces a
session/request_permission request instead, carrying the tool_call the
client has already been shown (as a session/update, sent before the ask) and
four options: allow once, allow always, reject once, reject always. Only an
explicit allow runs the call — a reject of either kind, a cancelled
outcome (the client closing the question without deciding), an unrecognised
option id, a malformed answer, a JSON-RPC error answer, or the client simply
going away all deny it, and the tool call is left unexecuted. The turn then
continues (or ends) exactly as it would after a local denial — same message,
same history entry, same next steps — so a client cannot distinguish "the
operator said no" from "the ACP wire said no" by the turn's behaviour.
A tool call escalated for destructive effect, credentials, a publish lease, or an untrusted origin is offered no "allow always" option at all — keryx's own rule for those is "always prompt, never remember".
allow_always is remembered by the client, not by keryx. keryx persists
nothing from an "allow always" answer; it is the client's job to answer the
identical next request itself, the same way an editor's own permission UI
would. keryx does not mint a local allowlist entry from it — a
keryx-side allowlist matched against an ACP answer it never showed a human
would be exactly the kind of "boundary" that has already gone wrong before.
If a client's very first answer to a session/request_permission comes back
as a JSON-RPC error (e.g. the client does not implement the method), keryx
remembers that for the rest of the connection and denies every later gated
call locally, without asking again — it does not retry a method a client
has already said it lacks, and it never treats "cannot ask" as "must be fine
to run".
Limits, plainly¶
- No transport but stdio. There is no HTTP or WebSocket ACP transport;
keryx acponly ever speaks newline-delimited JSON-RPC over the pipes the client gave it when it spawned the process. - No authentication.
authMethodsis empty andauthenticate/logoutare refused; keryx authenticates to model providers out of band, through its own credential store, never over this wire. - Writes and shell execution never leave the machine, as above — not a
missing feature so much as a decision not to give up
apply_patch's atomicity orshell_exec's streaming/approval behaviour for a capability keryx does not need on a machine it already shares with the client. - Not implemented at all:
session/resume,session/close,session/delete,session/set_mode— see the refusal table above for why each one specifically. - No cross-project
session/list. Only the ACP process's own project (or acwdyou pass) is listed; there is no "every session on this machine" view.
bus¶
The agent bus: keryx shells in every worktree of one clone see each other and
exchange short messages. The bus lives in the git common directory
(<git-common-dir>/keryx/bus/<project-key>/), outside .metaproject/, and
never writes flow state.
keryx bus list [--json]
keryx bus log [--since <seq>] [--limit N] [--json]
keryx bus send <@name|@all> [--kind notice|question|handoff|reply] [--reply-to <id>] [--json] <text…>
keryx bus pause <@name|@all> --reason <text> [--scope turns|git-publish|advisory] [--ttl 30m] [--json]
keryx bus resume <leaseId> [--json]
keryx bus prune [--json]
| Subcommand | Description |
|---|---|
list |
Live and stale peers — name, state, status, heartbeat age, branch, checkout and activity — and the active pause leases. Gone peers are hidden. --json prints both as JSON. |
log |
The retained events, oldest first. --since <seq> shows only events after that sequence number, --limit N only the last N, --json prints them as JSON. |
send |
Send a message as cli with a fresh sender id. @name must name a live instance: a name nobody holds is refused with unknown-recipient, one held only by stale or gone instances with recipient-not-live. @all is every instance. --kind (default: notice) accepts notice, question, handoff, or reply; --kind reply requires --reply-to <id>. --json prints { seq, id, resolvedTo }. |
pause |
Create a pause lease held by cli against a live @name or @all (--reason is required). --scope (default: turns) is turns (hold new main-agent turns), git-publish (escalate publish commands to an approval prompt), or advisory (delivered like a notice). --ttl (default: 30m) is 30m/2h/45s-style, with a minimum of 1 minute and maximum of 4 hours. The cli holder has no presence, so the lease's own TTL bounds it; at most one CLI-origin lease can be active in the clone. --json prints { leaseId, scope, targets, expiresAt }. |
resume |
End any lease by id, as cli — the operator's escape hatch from a terminal, regardless of who holds it. Resuming an id that is already gone is a silent no-op. --json prints { leaseId }. |
prune |
Remove presence records gone for more than 24 hours, inactive pause leases (each gets one lease-expired event), and rotated log segments beyond the retention bound. Live and stale peers are never touched. --json prints what was removed. |
Refusals print their code and exit non-zero (except where noted):
use-agent-tool(exit2):send,pauseorresumerun inside a keryx tool call, whereKERYX_TOOL_CALL=1is set on everyshell_execchild. An agent uses its bus tools instead (bus_send,bus_pause).list,logandprunestill work there.KERYX_SESSION_*variables alone do not trigger this.bus-disabled:send,pause,resumeandprunewhen the bus is off —KERYX_BUS=off, shell configbus.enabled: false, or a CI environment. The reason is named.listandlogstill read.unknown-recipient:sendorpausewhen@namedoes not match a live instance, or when the address is malformed.recipient-not-live:sendorpausewhen@nameis held only by stale or gone instances.recipient-is-self:sendorpausewhen the message or lease targets resolve only to this instance.lease-already-held:pausewhen the holder already has an active lease, or when a CLI-origin lease is already active anywhere in the clone and this request is also CLI-origin.ttl-out-of-range:pausewhen--ttlis less than 1 minute or greater than 4 hours.not-lease-holder:resumewhen the lease holder is notcliand not this instance.reply-without-replyTo:sendwhen--kind replyis used without--reply-to <id>.invalid-id:sendwhen--reply-tois not a valid UUID, orresumewhen the lease id is not a valid UUID.rate-limited:sendwhen the clone-wide CLI message rate exceeds 30 per minute.
shell behavior¶
Interactive behavior¶
/helplists every slash command available in the current mode (agentvschat). The registry isAGENT_SLASH_COMMANDS./status(chat and agent) opens a read-only inspector. The TUI modal always has Status and Context: last-turn tokens, a labelled estimate, and — when the provider reported one — the model context window, optional rate-limit headers, and DeepSeek/OpenRouter balance. A missing figure stays—; the bar never invents a 128k window. Workspaces and Flow tabs appear only when the session actually referenced a SAC workspace or a flow (runLink.sessionIdor an explicitflow 154//flows 154mention).ccopies the session id. Readline /--no-tuiprints the same rows./session-infoand/infoare not aliases./flowslists project flows, newest first (highest id, thenupdatedAt). In the TUI, the List tab uses↑/↓to move the selection; Enter or→opens Detail. On Detail,↑/↓scroll the body instead —[/](orp/n) switch to the adjacent flow./flows 154(id, padded id, or slug) opens one package directly. Readline prints the list, or one package when given an argument./statusand/flowsremain usable even while the main turn is busy./sessionsopens an interactive session picker in the TUI and switches the live shell to the chosen session. Sessions written bykeryx agents external runappear there too. They are markedacp:<agent>in the row's description, and typingacpin the filter finds them.keryx shell -cnever continues one: it continues the newest session of the shell's own kind.- Governance (TUI sidebar, always present, below Jobs). The row shows one of five states:
no report — click to run: there is no stored report.unreadable — click for reason: a stored report exists but is malformed or cannot be read.running…: a report is being built.last report <YYYY-MM-DD HH:MM>: taken fromgenerated_at, in UTC.failed — click to retry.
With no report, clicking the row runs the same buildGovernanceReport +
writeGovernanceArtifacts pair that bare keryx governance report runs:
current project, no filters. The run happens in the shell's own process, in
the background. The composer stays usable, a second click starts nothing, and
one toast appears when it finishes. It never goes through the agent's task
registry, so it never starts an agent turn. With a report, clicking the row
opens the report modal. /governance does the same from the keyboard.
The modal shows the stored report's generated_at, filters and
all_projects above two tabs (←/→ switches them).
Flows (the tab it opens on) lists the current project's flows, open ones
first and newest first, each with its summary: (expected outcome, tasks done,
tasks still open) and effect: (the first bullet of ## Outcome criteria in
its description.md, or not stated with the reason). ↑/↓ and j/k move
the selection. On an open flow, c runs the read-only completion check — the
same thing as keryx flow check-complete — and shows the PR's merge state and
every gate, with the command that fixes each failing one. The action row under
the list does what its key does when clicked. Once a check passed, the PR is
merged (or a direct merge was verified) and the flow has not changed since,
d asks you to type the flow id and press Enter, naming the checked-out
branch; any other key cancels. Then it runs flow complete, signed as the CLI
signs it with no --signed-by (KERYX_ACTOR, else the git identity), shows
the result, and re-runs the report. A flow created with
--require-confirmation is pointed at keryx flow confirm in a terminal
instead: the modal never mints a token. Check and complete run in the shell's
own process and never start an agent turn. A stored --all-projects report
lists only the current project here.
Report shows latest.md wrapped to the panel. ↑/↓ and j/k scroll
one line, PgUp/PgDn scroll one page. On either tab r re-runs the report in
the background and refreshes the modal when it finishes, and Esc closes it. A
malformed or unreadable stored report is never rebuilt behind your back:
clicking the unreadable row or /governance opens the modal, which gives
the reason and offers r to rebuild it. A report
written by another process after a failed run replaces the failed state.
Both artifacts are replaced atomically, latest.json last.
- Triggers (TUI sidebar, below Governance). The section is hidden when
.metaproject/triggers.json does not exist, and shows one error row when the
file, or the ledger, cannot be read. Row ages are repainted at least once a
minute. It shows:
- Project trigger spend, as keryx governance report computes it and in the
same format ($0.004, never rounded to $0.00). Runs that closed with
their cost not recorded are counted separately and never added in as $0. A
dispatch's spend reservation is not a run of its own once the run's closing
record (or keryx trigger resolve) exists.
- Open spend reservations, when there are any, with the USD they hold:
! 1 open · $0.5 reserved. An open reservation — a run in flight, or a
killed one nobody has resolved — is named only as open: it is never also
counted as "not recorded", and stays open until the run closes it or
keryx trigger resolve.
- One row per event-fired trigger: its name, enabled or disabled, and
either the last outcome with its age or never fired. A trigger whose
dispatch has network: true is marked NET.
Schedule-fired triggers get no rows here, only an N scheduled line; the
Schedules section owns them.
Clicking a row, or /triggers [name], opens a list+detail modal. A name that
matches no trigger stays on the list with no trigger named "<name>". Both
modals ignore keys while a permission prompt or another composer choice is
open.
- List keys: ↑/↓ to select, Enter to open Detail, [/] for the
previous or next trigger.
- Detail shows the entry exactly as keryx trigger list/status print it
(they share the same formatter) and whether its hook is installed. For a
dispatching flow-next it also shows provider/model, permission mode,
ceiling, max seconds, max attempts, the unattended roster, and network with
the full NETWORK ON warning.
- Detail also lists the last ledger records (outcome, cost, refusal code,
denials) and every open reservation with its exact
keryx trigger resolve <runId> --spent <usd> command. The TUI never
resolves a reservation itself.
- Run now: r arms it, y confirms, and any other key cancels. For a
dispatching entry the prompt names its ceiling and whether the network is
ON. A disabled or malformed entry cannot be armed, and the modal says why.
A confirmed run is keryx trigger run <name> executed as a child process
of the same keryx build the shell runs (its own interpreter and entry
script, or the binary itself for a compiled build — never whatever keryx
is on PATH), in the project root, without the shell's KERYX_SESSION_*
variables. It is therefore bound by exactly the CLI's locks, budgets, spend
reservations, refusals and unattended floor. The modal shows running…,
then the new ledger record and the tail of the run's output, and one toast
appears.
- Quitting while a run is in flight does not stop it. The child runs
detached, in its own process group, and writes its output to its own
file, .metaproject/data/trigger/run-now/<name>-<startedAt>.log, in a
directory that ignores itself in git. The newest five logs per trigger are
kept. The log is never written through a symbolic link: if
.metaproject/data/trigger, run-now or the log file is a symlink (or not
what it should be), run-now refuses and the modal says why. On exit the shell prints one line naming the trigger and
the log. The run writes its own closing record, which the sidebar shows on
the next start. A second run-now of the same trigger from a restarted shell
is refused by the CLI's own lock (lock-refused, or dispatch-locked for a
dispatching flow-next), not by the TUI.
Both sections poll runs.jsonl, triggers.json and the governance
latest.json every few seconds, and again after every settled turn. They
repaint only when one of those files changed, so a run finished by a git hook,
CI or another shell shows up without a restart. /governance and /triggers
also work while a turn is busy.
- /theme (chat and agent) with no argument opens a picker modal: a theme
list on the left, a live preview (assistant markdown, a code block, tool/
side/chip/ok/error samples) on the right. Arrow keys move the highlight and
repaint the preview instantly; the palette itself only applies on Enter or
a click on [ Apply ] — Esc/close leaves the current theme untouched.
/theme <name> still applies immediately without opening the picker.
Readline / --no-tui supports only the immediate-apply form.
The chosen palette also drives existing and future transcript content:
prose, headings, emphasis, inline code, syntax roles, diff rows, and table
chrome repaint together. In assistant Markdown, use a language-tagged fence
such as typescript for syntax color, diff for themed additions and
deletions, and text or txt for literal preformatted output. GFM pipe
tables render as responsive bordered tables; escape a literal separator as
\| or place it inside inline code.
- Structured execution plans need no operator command. During multi-step work,
the agent can create and update a session-backed plan with proposed,
pending, in_progress, completed, blocked, and skipped states. When
present, its current window appears automatically under Plan in the TUI
sidebar and returns after session resume; clicking the section opens the whole
plan in a modal (Plan and Meta tabs). proposed means the item is
awaiting YOUR approval and is not work in progress: it never makes the agent
continue on its own, so a plan published for approval is a legitimate place for
a turn to end. Orchestrator pipelines publish their own steps into the same
plan — the session-plan-bridge rule holds the ids and the status translation
from keryx job status / keryx flow status — so a job or Flow run is
watchable while it runs, not only once it reports. Approving is the operator turning those items into
pending/in_progress work. This is separate from /plan, whose name
predates the panel and still means the operator-controlled read-only mode.
- /search-provider configures and tests web search providers for web_search
(DuckDuckGo is the default and needs no setup): run with no arguments to open
a wizard (select provider → enter fields/credential when required →
active-toggle → test); pass provider id plus key=<value> (for keyed
providers) to configure and validate one directly.
- /search-connect selects the active search provider: run with no arguments to
open a picker over DuckDuckGo plus any tested providers, or pass an ID to
switch directly. duckduckgo can be selected without a prior test.
- /delegate <agent> <task> hands a bounded read-only task to a vendor coding
CLI hosted as a child agent (keryx agents external list for the ids). It is
refused with a named reason when the capability is off — which is the default.
The child appears in the subagent sidebar with a ⤳ marker and opens a
Work / Meta / Command modal: the live transcript, the run's metadata, and
the exact launch argv plus how to continue the session by hand. Operator
messages to a running child use the same /queue semantics
(remove/edit/force); force here is kill-plus-resume, not an abort. See
the harness page.
- /remote-control [name|off|status] mirrors this session into a topic of a
Telegram supergroup. Off by default, and it only works when keryx serve
has remote control configured (see
serve). /remote-control <name> creates the
topic (on lets serve pick <project>-<short session id>); status, or no
argument, prints the state — off, on or offline when serve is
unreachable — the topic, the last heartbeat and recent events; off removes the
topic. A line sent from the topic runs as if typed here, shown as tg ❯ in the
transcript and [tg] in the queue panel. In the full-screen shell the sidebar
row and the /remote-control modal (key o toggles) show the same state; the
readline shell (--no-tui) can only print that it is off. Each time it is
turned on or off is recorded in the session's history (see
sessions).
/history [N] posts the last N messages of this session (1 to 20, default 10:
what you typed and the agent's final answer of each turn, redacted, one message
each) into the topic; typed in the shell, sent from the topic, or key h in the
modal. A resumed session whose topic /remote-control <name> just created gets
the last 10 by itself, once.
Slash commands typed in the topic run only if they are on a fixed list
(/help and the Telegram menu show it): text commands such as /status, /new
and /clear (same topic, one separator line), button pickers (/model,
/connect, /resume), and commands that ask first with a Yes/No button
(/mode trust|auto, /plan off (bare /plan shows the mode and does not ask),
and /delegate, /external and /external-agents, which name the agent and say
it is external and paid). The rest, for example /mcp, /guard, /route,
/editguard, /provider, /channels, /schedule, /rewind, /conform, /ci,
bare /theme and bare /think, stay local (they open a panel, form or picker in
the shell) and are refused in the topic with the reason. A command from the topic
never cancels the turn running in the shell; /stop in the topic ends the run
that topic started. A Telegram turn runs in trust by default, with the same
floors as a shell trust turn, and an approval question carries an Always
button (see /permissions and /remote-policy). The modal
has a Commands tab with the recent ones. Full list and rules:
Drive keryx remotely.
- /channels [status] connects Telegram to this machine, from any shell. The modal
lists Telegram: when it is not connected there is one button, Connect; when it is,
Test and Disconnect. Connect asks for the bot token (hidden, paste works, never in
the transcript, history or logs) and writes remote/bot-token and
remote/config.json itself, mode 600. It then shows a one-time code (valid 10
minutes, single use); the first private message to the bot carrying it adds that
sender to the allowlist. Add the bot to your group as an administrator with the
manage topics right and the group id is taken from that event; the modal says what
is missing (a group without topics, a bot without the right). Test sends one
message naming this machine to the General topic and shows delivered or the
reason. Disconnect asks serve to delete every topic and stop polling, then
erases the token and config; with serve down it erases the files and says the
existing topics stay in the group. The sidebar row reads Telegram: off | pairing
| connected | serve down; the readline shell (--no-tui) prints the state and
how to connect from the full-screen shell. serve offers these routes on
loopback whether or not Telegram is configured, and the bot token never goes in
a request. Use one bot per machine.
- keryx shell supports a hard stop for a running main turn via
/interrupt.
- Session history is durable during a turn: user input and tool results save
immediately; streamed assistant text checkpoints every 300 ms and is flushed
by /interrupt.
- If the main turn is busy, submitting a normal message opens a selector:
Main queue (default) or Side-1 (a read-only worker, outside main
history, single slot by default; the transcript notes ◦ side-1 queued
while it processes). A message sent to the main queue appears as qN (p)
in a dedicated panel above the composer input and drains FIFO right after
the current turn completes. Each queued item shows clickable Force/Edit/Delete
buttons in the panel; Ctrl+Q opens a keyboard-only selector for the same
actions (↑/↓ select, ←/→ choose action, Enter fire, Esc exit). Each item also
says why it waits ("waits for the current turn", "waits for approval of:
[Force]. The /queue text
command (remove/edit/force) remains available and lists the same text.
Every approval modal (and its Telegram relay) starts with one why: line naming
the cause; see Why am I being asked?.
When side-worker context is still processing, queued questions do not block the session state and still see recent context about the busy main turn.
Reasoning effort and output budget¶
/reasoning [off|minimal|low|medium|high|xhigh|max]sets the session's thinking effort for the main agent turn; no argument prints the resolved level and which tier it came from. Precedence, highest first: this session's/reasoningoverride, thenKERYX_REASONING_EFFORT, then the persistedreasoningEffortin the shell config, thenoff(no reasoning requested — the default). Setting it also persists to the shell config, so it survives a restart. Readline (--no-tui) has the same command.- Each native provider maps the requested level onto its own knob and clamps a level it does not support to the nearest one it does, rather than sending an unrecognized value:
- Anthropic — current-generation models (Opus/Sonnet 5, Fable, Mythos,
Opus/Sonnet 4.6 and newer) use adaptive thinking
(
thinking: { type: "adaptive" }) withdisplay: "summarized"forced on — the default arrives with empty thinking text — and the level passed asoutput_config.effort. The4.5generation (Haiku, Sonnet and Opus 4.5) and any Claude 3.x/2.x model use budget-based thinking instead (thinking: { type: "enabled", budget_tokens }), withbudget_tokensfixed per level (2048 low, 4096 medium, 8192 high, 16000 xhigh/max) andmax_tokensraised to make room for it. Anthropic has nominimallevel (clamps tolow), and Opus/Sonnet 4.6 have noxhigh(clamps tohigh). - OpenAI (Responses API) — passed as
reasoning.effort, with a requested summary. OpenAI's own vocabulary isminimal|low|medium|high;xhigh/maxclamp tohigh. - Gemini —
generationConfig.thinkingConfig, always requestingincludeThoughts: truealongside the depth control.gemini-3*models usethinkingLevel(loworhigh—mediumclamps tohigh); older or unrecognized model ids usethinkingBudget, a token budget per level. No effort requested means nothinkingConfigat all. - Custom OpenAI-compatible providers ignore this control. Their
reasoning shape is configured per-provider in
llm-providers.jsoninstead — see Custom-provider reasoning configuration below./reasoning <level>still prints a note naming the active provider when it has noreasoningentry there. - Output budget. The main turn's per-request output-token budget
defaults to 8192 — the tightest hard ceiling among currently supported
providers (DeepSeek's OpenAI-compatible
max_tokenscap). Precedence, highest first: theKERYX_MAX_OUTPUT_TOKENSenv var, a custom provider's ownmaxOutputTokensinllm-providers.json(set via the "add custom provider" wizard, or by hand — a positive integer), the operator's globalmaxOutputTokensshell-config setting, then the 8192 default. No upper ceiling is enforced on an override. A configuredtemperature(same wizard, any finite number) is sent on every request to that provider once one is set — absent, notemperatureis sent at all, unchanged from before this setting existed. - Subagent budgets. A
spawn_subagentchild stops on its round budget (max_rounds, default 40, capped at 200), its wall-clock deadline (KERYX_SUBAGENT_TIMEOUT_MScan tighten it;0disables it) and the no-progress detector. Themax_tool_callsvalue the parent model passes is advisory: it never stops the child. A hard tool-call cap applies only when the operator setsKERYX_SUBAGENT_MAX_TOOL_CALLSto a positive integer (unset or0means no cap). From 80% of any of these limits (advisory calls, the configured cap, the round budget) every tool result the child receives ends with one line saying what remains and telling it to return its result now. When the child reaches the configured cap or its round budget, or stalls, it gets exactly one more round in which the only tool issubmit_result(status: "partial",summary,result). That round is the only request ever sent past the round budget, so a child makes at mostmax_rounds + 1requests (25 at the cap). The parent seesstatus: BudgetExhausted (<used>/<limit> <calls|rounds>)orstatus: NoProgress (…)as the first line of the result, followed by the submitted result orno result submitted; the fleet row shows the child as failed, not done. - Streaming and timeouts. Every provider adapter (Anthropic, OpenAI,
Gemini, and the OpenAI-compatible engine) streams the reply incrementally
instead of waiting for the full response. Two independent deadlines guard
a stalled connection: a first-byte timeout (no byte at all since the
request was sent) and an idle timeout (no further chunk since the last
one) — both default to 120 seconds and have no user-facing setting today.
Separately, a custom OpenAI-compatible provider may also set an opt-in
overall
timeoutMs(same wizard, or by hand inllm-providers.json— a positive integer) that bounds the WHOLE chat/completions call from the moment it starts, independent of the first-byte/idle deadlines above; absent, no such overall timer runs and only the two deadlines above (and the caller's own cancellation) can end a stalled call. A first-byte/idle timeout surfaces as a retryableunavailableprovider error, the same class as a dropped connection; the configuredtimeoutMsfiring (like an operator/UI cancellation) surfaces as acancellederror instead. Whichever fires first ends the call with exactly that one terminal error — never a second, duplicate error alongside it. -
Tab or Right accepts the next-step hint shown under the composer; starting a new turn, or typing or pasting into the composer, cancels an in-flight or already-shown hint. Enter on an empty composer no longer sends the hint — only Tab/Right accept it.
-
While the model reasons, the busy line switches to
thinking…on the first reasoning fragment and shows the latest reasoning lines next to the elapsed time. When the reasoning ends it becomes a collapsed block headed◆ thought for 12s · 1.8k tokens; the token count appears when the provider reports it (OpenAI, Gemini). Reasoning the provider hides is shown as◆ thought · hidden by provider. /thinkwith no argument expands or collapses the last reasoning block;Ctrl+Oandy(copy) work on it like on any other transcript block./think auto|expand|hidesets how reasoning is shown and is saved asthinkDisplayin the shell config:auto(default) collapses the block,expandshows it expanded,hideshows neither the live preview nor the block, and the hidden reasoning is not kept for copying. The plain (non-TUI) shell prints◆ thought for 12s (3 lines)and followshidetoo.
harness¶
Drive the agent execution loop non-interactively — the same loop shell
runs, without a terminal attached. This is the scriptable and CI-facing surface.
keryx harness run --provider <p> --model <m> [--base-url <url>] [--record <path>] "<prompt>"
keryx harness exec [options] -- <path> [args...]
keryx harness extension --spec <path>
keryx harness wave --spec <path>
keryx harness replay --record <path> [--fixture <path>] [--write-fixture <path>] [--json]
| Subcommand | Description |
|---|---|
run |
Execute one prompt through the run loop against the named provider and model. fake is a deterministic in-process provider, which is what makes the loop testable without a network. |
--provider accepts anthropic, ollama, fake, and the OpenAI-compatible
gateways — openrouter, deepseek, zai, zai-coding, cerebras, groq,
moonshot, grok. keryx shell offers the same set through its picker, which
lists each provider with the environment variable it reads.
Operator-defined OpenAI-compatible providers can be added on top of that list
by registering them in ~/.local/share/keryx/llm-providers.json; the
/provider wizard in keryx shell has an "add custom provider" entry that
writes this file for you (name → URL → key → models → temperature (optional)
→ max output tokens (optional) → request timeout ms (optional)). A custom
name colliding with a built-in provider is rejected. Custom providers may
target private LAN hosts (RFC1918/CGNAT) — an explicit opt-in that built-in
providers never get; loopback and link-local metadata addresses stay denied
regardless.
The three optional wizard fields persist straight onto the provider's
llm-providers.json entry and are consulted on every request: temperature
(any finite number, including 0) is sent verbatim; maxOutputTokens and
timeoutMs must each be a positive integer when set — a 0, negative, or
fractional value is rejected by the wizard and by a hand-edited file (the
whole entry is dropped rather than accepted). See "Output budget" and
"Streaming and timeouts" under
Reasoning effort and output budget
above for how each is applied.
| exec | Run a subprocess under the containment options below. |
| extension | Run a declared extension from a spec file. |
| wave | Run a declared multi-agent wave from a spec file. |
| replay | Check that a replay fixture still describes the run it was built from. run --record <path> writes the record; replay --record <path> builds a fixture from it and validates, --write-fixture keeps that fixture, and --fixture compares against a kept one. A divergence prints a typed mismatch naming the field and exits non-zero. |
harness replay is validate-log: it recomputes hashes from a recorded run and
compares them. It does not re-execute the run, so it answers "is this
fixture still true of this record", not "would this prompt behave the same
today". Nothing is contacted — no provider, no tool, no network.
harness exec containment options¶
| Flag | Description |
|---|---|
--allow-env KEY (repeatable) |
Pass one environment variable through. The default is to pass none. |
--max-runtime-ms N |
Wall-clock bound on the child. |
--allow-real-subprocess |
Permit a real subprocess at all. Without it, execution is refused. |
--allowed-domains a,b |
Restrict network egress to a domain allowlist, served by a loopback proxy that reports each allow/deny ruling. macOS only — on Linux this refuses rather than degrading to full host network. |
--mask-env NAME@host |
Mask a credential toward a specific host. |
--tls-terminate |
Terminate TLS so HTTPS traffic can be masked. macOS only. |
--mask-mode auto\|manual\|off, --auto-mask |
Credential-masking mode. Resolution order is env → project → global → built-in; the built-in default is auto when the restricted sandbox is on. |
Masking without TLS termination fails closed — it does not proceed with an unmasked connection. Spawn failures carry structured diagnostics rather than a bare exit code.
Custom-provider reasoning configuration¶
An entry in llm-providers.json (see above) may carry a reasoning block
that tells the OpenAI-compatible adapter how to read — and later replay — a
gateway's reasoning output. Absent, reasoning is read the default way — with
two exceptions: a provider whose baseUrl host is api.minimax.io or
api.minimaxi.com (any path) with no reasoning block of its own gets
{ "format": "split", "requestParams": { "reasoning_split": true }, "replay":
"minimax" } automatically, and one whose host is api.deepseek.com — the
built-in deepseek provider included — gets { "format": "field", "replay":
"deepseek" } automatically, so a tool-using round always replays its prior
reasoning_content back and thinking mode never 400s on the follow-up
request. MiniMax's own default mode sends every reasoning phrase twice
(inline in content as <think>…</think> AND again in a reasoning
field), so its preset asks it for out-of-band reasoning instead. An explicit
reasoning block on a MiniMax or DeepSeek entry always overrides its preset.
{
"schemaVersion": 1,
"providers": {
"my-minimax": {
"name": "my-minimax",
"baseUrl": "https://api.minimax.chat",
"models": ["MiniMax-M3"],
"reasoning": {
"format": "split",
"requestParams": { "reasoning_split": true },
"replay": "minimax"
}
}
}
}
reasoning.format:
| Value | Use when | Behavior |
|---|---|---|
"field" (default when reasoning is absent or format is omitted) |
The gateway sends reasoning in a reasoning/reasoning_content delta field, separate from content (DeepSeek, OpenRouter, vLLM, most gateways). |
The field is read as-is; a literal <think>/</think> tag leaked into it (and its one adjacent newline on each side) is stripped, same as "split" below. |
"inline-tags" |
The gateway writes reasoning INSIDE delta.content as <think>…</think> — MiniMax's own default shape, and typical of Qwen3 or a DeepSeek-R1 distill served with no reasoning parser in front of it. |
keryx strips the tags out of the visible content and surfaces the tagged text as reasoning, via ThinkTagParser. content is the ONLY reasoning source under this format — a reasoning/reasoning_content/reasoning_details delta is ignored, so a gateway that sends both (MiniMax's own default mode) never double-emits the same reasoning text. |
"split" |
The gateway can be asked, via requestParams, to send reasoning out-of-band instead of inline. |
delta.content passes through unchanged; reasoning is read from reasoning_content/reasoning_details as usual. A literal <think>/</think> tag a gateway echoes into that field (MiniMax's split-mode stream was observed to end with one) is stripped out, along with its one adjacent newline on each side. |
reasoning.requestParams — an object shallow-merged into the request body
AFTER every field keryx builds itself. It cannot override model,
messages, stream, or tools — those keys are dropped from the merge;
every other key passes through, including a provider-specific flag like
MiniMax's reasoning_split.
reasoning.replay — which shape a resumed transcript uses to replay this
provider's own past reasoning: "none" (default), "deepseek" (echoes an
owned reasoning_content field back for a tool-using round), or
"minimax" (an owned raw_content replay item replaces content).
Two worked examples, alongside the inline-tags one above:
- MiniMax, asking for out-of-band reasoning and replaying it MiniMax's way:
- DeepSeek, default field-based reasoning with DeepSeek-shaped replay:
Per-provider output budget, temperature, and timeout. maxOutputTokens,
temperature, and timeoutMs are sibling fields of reasoning (not inside
it, and each independent of it): maxOutputTokens overrides the main turn's
output-token budget for just this provider, temperature is sent on every
request to it, and timeoutMs bounds the whole chat/completions call — see
Reasoning effort and output budget
above.
init¶
Initialize the .metaproject/ workspace in the current directory: scaffold
directories, enable the optional modules, optionally install git hooks, and write
the metaproject.json manifest. Re-running init over an existing workspace
updates managed files but never clobbers seeded user files or data/.
| Flag | Description |
|---|---|
--yes, -y |
Non-interactive: accept every module default (enabled) instead of prompting. |
--help, -h |
Print init usage and exit. |
--gdskills-profile <profile> |
Set the gdskills install profile (minimal, recommended, full, custom); defaults to recommended. |
Module flags — each of the 9 modules is enabled by default; pass its
--no-<module> flag to disable it:
| Flag | Disables module |
|---|---|
--no-gdgraph |
Dependency graph. |
--no-gdctx |
Compact context commands. |
--no-gdwiki |
Project knowledge base. |
--no-gdskills |
Working-skills subsystem. |
--no-health |
Code-health quality gate. |
--no-testing |
Testing context / reports. |
--no-memory |
Long-term project memory. |
--no-tasks |
Flow / Task Manager lifecycle. |
--no-security |
Metaproject Security (input/output + artifact scanning). |
Hook flags — git hooks are installed only for enabled modules. Under --yes
most default on; the testing pre-push hook stays off even under --yes (opt-in).
Pass the matching --no-*-hook flag to force a hook off:
| Flag | Skips hook |
|---|---|
--no-gdgraph-hook |
gdgraph post-commit hook (it rebuilds the graph; KERYX_GDGRAPH_HOOK_REBUILD=0 downgrades it to a reminder instead). |
--no-gdskills-hook |
gdskills post-commit hook. |
--no-health-hook |
health post-commit hook. |
--no-testing-post-commit-hook |
testing post-commit (refresh) hook. |
--no-testing-pre-push-hook |
testing pre-push (gate) hook. |
--no-security-hook |
security pre-push gate hook. |
--no-security-agent-hook |
security Claude Code agent hook (.claude/settings.local.json). |
Capability flags — opt-in ceilings that are off by default. Each has a
matching --no-<capability> form (the default) that keeps the generated
metaproject.json byte-identical to a plain init; only passing the positive
flag touches the manifest:
| Flag | Enables capability |
|---|---|
--mcp / --no-mcp |
The opt-in MCP server module (mcp serve). Interactively, init also offers this as a question (default No); --mcp/--no-mcp set it non-interactively. Wire a client config afterwards with mcp install. |
--treesitter / --no-treesitter |
The gdgraph tree-sitter symbol layer (optional web-tree-sitter dependency). |
--testing-tia / --no-testing-tia |
The testing coverage-map test-impact analysis (drives map-first test run --changed). |
The two security hooks are offered only when the security module is enabled and
default on (confirm prompt; accepted under --yes). The pre-push hook adds a
managed block to .git/hooks/pre-push that scans changed files with
keryx security scan before a push — it warns in advisory (the default)
and blocks the push in enforced/ci/gateway mode; it coexists with the testing
pre-push hook and any user content. The agent hook merges (merge-safe, never
clobbering existing settings) two Claude Code hooks into
.claude/settings.local.json (the tracked .claude/settings.json when
agentEntrypoints.claudeSettings has scope shared):
UserPromptSubmit → security check-input and PreToolUse(Write|Edit) →
security check-output, advisory by default.
Agent entrypoints and ignore rules. By default init puts none of these
in a file the repository tracks: the routing block goes to CLAUDE.local.md and
AGENTS.override.md (generated from AGENTS.md; skipped with a message when
there is no AGENTS.md), Claude hooks to .claude/settings.local.json, and the
ignore rules to .git/info/exclude. AGENTS.md, CLAUDE.md, .gitignore and
.claude/settings.json are left alone. See
where the block goes
for the scopes, the Codex override/skip modes, and the known limits.
status¶
Print the local Metaproject status. Read-only — never writes.
Reports one of: not initialized (no .metaproject/), incomplete (missing or
invalid metaproject.json), or ready — in which case it prints the workspace
root and each module as enabled or disabled.
modules¶
View and toggle Metaproject modules. Enabling or disabling a module re-runs
init with the appropriate --no-<module> flags to add or remove its scaffold.
| Subcommand | Description |
|---|---|
status (alias list) |
Print each module and whether it is enabled. Also the default in a non-interactive (non-TTY) context. |
enable <name> (alias on) |
Enable a module by its metaproject.json key and re-scaffold it. |
disable <name> (alias off) |
Disable a module and drop it from the workspace. |
(no argument) / interactive / -i |
Interactively toggle modules on/off, then apply via init. |
Module names are the manifest keys: gdgraph, gdctx, gdwiki, gdskills,
health, testing, memory, tasks, security.
projects¶
Manage the user-global project registry — the set of projects on this
machine, and the addressing keys a remote transport routes by. keryx init
registers a project into it automatically; nothing on the machine knew the
project set before this registry existed.
| Subcommand | Flags | Description |
|---|---|---|
list (default) |
--json |
Print every registered project with its id and root path. --json emits the machine-readable form. |
register <path> |
— | Register a project root explicitly. Idempotent. |
forget <id> |
— | Remove a project from the registry. Removes the registry entry only — it never touches the project's .metaproject/ workspace or any file under its root. |
The registry is user-global (not per-project) and is what keryx serve resolves
a request's target project against. A request naming an unregistered project is
refused; there is no fallback to "some other project".
auth¶
Authorize a vendor-sanctioned subscription (SuperGrok, ChatGPT Plus/Pro, GitHub Copilot) or inspect that state. Credentials are written to the user-global auth.json at mode 0600. Secrets are never printed.
Claude Pro/Max, Gemini Google-account login, and DeepSeek subscription OAuth are refused at the point of choice.
keryx auth list [--json]
keryx auth login <provider>
keryx auth logout <provider>
keryx auth status <provider> [--json]
| Subcommand | Flags | Description |
|---|---|---|
list |
--json |
Authorized providers (method and expiry, never a token) and which subscription logins are offered. |
login |
<provider> |
Run the provider's device authorization grant. Prints a verification URL and code (opens a browser only when a graphical session is present); keryx polls for the token. |
logout |
<provider> |
Discard the stored grant. |
status |
<provider>, --json |
Method, state, expiry, refreshability — never the secret. |
/provider in keryx shell offers the same SuperGrok vs API-key choice for xAI.
OpenAI API and ChatGPT subscriptions¶
/provider offers two independent entries:
| Entry | Provider ID | Credential and usage |
|---|---|---|
| OpenAI API | openai |
Platform API key (OPENAI_API_KEY), billed through the API account. |
| ChatGPT / Codex | openai-codex |
ChatGPT subscription login, using the account's Codex access and limits. |
For a ChatGPT subscription, enable device-code login in your ChatGPT security
settings, then choose ChatGPT / Codex in /provider. Keryx displays a
verification URL and a one-time code and opens the browser when available.
Sign in to your account in that browser and enter the code. On a headless
machine, open the displayed URL on another device. Escape cancels the Shell
login. The command is /provider, not /provide.
The same login can be started before opening Shell:
Then choose /provider → ChatGPT / Codex and a model returned by the
subscription service. /connect lists connected providers and offers Test and
Disconnect; CLI equivalents are keryx providers test openai-codex and
keryx providers remove openai-codex. A successful model-list probe verifies
authentication and discovery; send a short prompt to verify inference too.
For ChatGPT / Codex, Keryx discovers the current stable Codex client version
from official npm metadata and uses it to request the live subscription model
catalog. It does not install Codex. The discovered version is cached for one
day in codex-catalog-version.json in Keryx's user-global config directory.
Normal model discovery refreshes an expired cache on demand; there is no
background polling. Test in /connect and
keryx providers test openai-codex always attempt a fresh version lookup,
even when the cache is still fresh. Subscription credentials are never sent
to npm. If npm is unavailable, a previously discovered version is reused;
without a cached version, discovery reports the lookup failure. Run Test
again once npm is reachable to refresh the catalog version.
OAuth tokens stay in Keryx's owner-only credential store and are refreshed
before use. They are never exported as OPENAI_API_KEY. Existing OAuth grants
saved under the old openai name remain readable by openai-codex. Disconnecting
the subscription removes its canonical and legacy OAuth grants, not the API
key. Disconnecting OpenAI API leaves subscription credentials intact.
If authentication is refused or a refresh token is no longer valid, repeat
keryx auth login openai-codex. Device-code availability and model access depend
on your account and workspace settings. See OpenAI authentication.
Offline integration tests cover the protocol; a live login and prompt with
your own subscription remain the final account-specific check.
providers¶
Report over the provider configuration — the built-in OpenAI-compatible
registry plus the operator-defined entries in llm-providers.json — plus, since
flow 304, test and remove: the CLI form of the /connect row buttons
("Test connection" and "Disconnect"). list and cross-family stay read-only
and network-free, exactly as before; test makes ONE network call (the live
/models probe), status (flow 309) reads or refreshes the live model
catalog, and remove writes to disk only after confirmation.
keryx providers list [--json]
keryx providers status [--json] [--refresh]
keryx providers cross-family [--opt-in] [--session-provider <id>] [--session-model <id>] [--from-shell-config] [--json]
keryx providers test <name> [--json]
keryx providers remove <name> [--yes]
Bare keryx providers (no subcommand) prints the same output as
keryx providers status (flow 353, AC7) — the summary an operator typing
the bare noun actually wants. keryx providers --help still prints usage.
| Subcommand | Flags | Description |
|---|---|---|
list |
--json |
Providers this operator has actually configured — a custom entry in llm-providers.json, or a built-in with a resolvable credential — and the model family of each. Read-only, network-free. |
status |
--json, --refresh |
The live provider catalog (flow 309): per connected provider, its status (ok / auth-failed / unreachable / timeout / not-supported), its live model count (or the curated offline count, clearly marked, when the live fetch failed), its balance when the provider exposes one (OpenRouter, DeepSeek — never a guessed number), and how old that reading is. An unconnected provider is never probed and never listed. A fresh cache (already refreshed by keryx shell starting up, /connect, or providers test) answers immediately; --refresh forces a fresh probe of every connected provider now. |
cross-family |
--opt-in, --session-provider <id>, --session-model <id>, --from-shell-config, --json |
Decide whether review may run on a different model family than authored the change, and print the record the round should carry. The authoring session comes from the flags, else KERYX_SESSION_PROVIDER/KERYX_SESSION_MODEL; the selection keryx shell persisted only with --from-shell-config. |
test |
<name>, --json |
Run that provider's live model-list probe (the same one /connect uses to decide what's "connected") and report ok with the model count, or the humanized failure reason. Exits non-zero on a failed probe. Also updates that provider's entry in the status catalog. |
remove |
<name>, --yes |
Disconnect that provider: remove its saved API key, its OAuth grant, or its llm-providers.json entry (together with any saved base-URL/model-param override) — whichever one it actually has. Asks for confirmation on a terminal; refuses without one unless --yes. A provider whose only credential is an environment variable you exported yourself is refused outright — the command names the exact variable to unset. Disconnecting removes only keryx's LOCAL copy of the credential: an OAuth grant is deleted from auth.json with no vendor revoke call, and a removed API key simply stops being read (the key itself stays valid at the vendor until you revoke it there yourself). |
Live provider catalog¶
keryx shell refreshes this catalog during startup loading (a "checking
providers…" step that never blocks the first prompt — a hung provider just
never fills in), and /routing's flat model picker and /connect read the
same cache instead of a stale, hardcoded model list. The cache lives at
provider-catalog.json in the per-user keryx config directory, mode 0600,
and never carries a credential — only name/status/models/balance/
fetchedAt.
Cross-family review¶
Reviewing with a different model family than authored the code is worth ~8–10 recall points in the 1,000-PR study this feature is built on (Claude→Claude 53.7% vs GPT→Claude 62.0%; GPT→GPT 50.5% vs Claude→GPT 60.0%).
Three properties, each of which the command enforces rather than describes:
- Opt-in. Without
--opt-inthe answer issingle-family, whatever is configured. Dispatching to another provider spends tokens and sends the change to a second vendor; that is a decision, not an optimisation. The options that were declined are still listed, because "we chose not to" and "there was nothing to choose" are different facts. - Recorded, and read back. The
--jsonoutput is across_family_reviewblock namingauthor_familyandreviewer_family. Hand it to the round:
keryx providers cross-family --opt-in --json > cross-family.json
keryx review ingest --report round.md --ref <ref> --cross-family-review cross-family.json
keryx review status <review-id>
review ingest writes it onto manifest.cross_family_review; review status
— a later, separate invocation — reads it off disk, prints it, and exits
non-zero on a record that contradicts itself: cross-family naming no
reviewer, naming the family that authored the change, or naming a reviewer that
was never among the recorded candidates. A round that recorded nothing reports
not recorded, which is not single-family: nobody decided.
- Degrades, never fails. With no second family configured it reports
single-family with a stated reason and exits 0. One vendor is a normal
configuration.
A gateway (openrouter) or a local runner (ollama, rapid-mlx) is deliberately
not classified as a family — it fronts many. Such a provider contributes
candidates only through the specific models it lists, so a round is never
recorded as cross-family when both sides in fact ran the same vendor.
routing¶
The routing table — flow 305 — maps a task category to a model, so
different kinds of work land on different providers/models without running
/model before every turn. Two config layers, same precedence pattern as
security.config.json, plus flow 327's automatically-built derived
layer: explicit per-call override > per-project routing.config.json >
per-user entry (shell config) > derived (flow 327, below) > session
default.
Categories (PRD docs/requirements/keryx-jev-router/PRD.md §4): default,
review, subagents, quick, coding, planning, docs, unattended.
Only review (keryx review tier) and subagents (spawn_subagent) are
wired in this release — resolved automatically at their call site; the
rest are catalogue entries the table/CLI//routing already support, ready for
a later flow to wire up without a second migration.
Not connected falls through. An assignment naming a provider the operator
has not connected — or a model that provider does not list — is treated as
unresolved at its layer and falls through to the next one (project -> user ->
session default), at every call site (review tier, spawn_subagent,
keryx routing list, /routing). "Connected" is the same notion /connect
uses (configured/detected providers and their reported models) — never an
extra live network probe on every resolution. keryx routing list and
/routing show the rejected entry:
A project routing.config.json requires approval. Because it is a
COMMITTED file, a repository could otherwise silently steer which model
reviews its own diff. A project entry takes effect only after the operator
has approved its CURRENT content once — a content fingerprint recorded per
user, keyed by project root (the same "approve the exact thing that will run"
shape .keryx/mcp-servers.json project-scope trust already uses). Until then,
and whenever the file changes afterward (a pure reformat does not count — the
fingerprint is over the validated category table, not the raw bytes), its
entries are ignored, with a visible notice in keryx routing list, /routing
and keryx review tier's output. keryx routing trust shows the file's
entries and then approves it; a routed review model is printed with
routed: true, never folded into tier_resolution: "discovered" (which
means the TIER, not routing, picked the model).
keryx routing list [--json]
keryx routing set <category> <provider>/<model> [--user|--project]
keryx routing set <category> <provider> [--user|--project]
keryx routing unset <category> [--user|--project]
keryx routing trust
keryx routing profile list [--json]
keryx routing profile set <provider>/<model> --tier|--price-in|--price-out|--context|--priority <value>
keryx routing stats [--json]
| Subcommand | Flags | Description |
|---|---|---|
list |
--json |
Every category, its resolved assignment (session default, <provider>/<model>, <provider> (provider default), or auto (derived from <provider>'s models) -> …), which layer answered (project, user, derived, or default), and — when applicable — the not-connected or unavailable entry it fell back from. |
set |
<category> <provider>/<model>, --user|--project |
Pin an exact model for a category. Default layer: --user. A --project write is not auto-approved — run keryx routing trust afterward. |
set |
<category> <provider>, --user|--project |
Pin a provider's own default model for a category (no /model — the "provider default" form). |
unset |
<category>, --user|--project |
Clear a category back to session default. Default layer: --user. |
trust |
— | Print routing.config.json's entries and approve its current content, so the project layer starts applying. |
profile list |
--json |
Every stored model profile: strength tier, input/output price per million tokens, context length, priority, each field's source, and availability. |
profile set |
<provider>/<model>, --tier|--price-in|--price-out|--context|--priority <value> |
An operator correction to one profile field — stored with source: "operator", never overwritten by a later refresh. |
stats |
--json |
Real measured task cost per (provider, model, category): sample count, median tokens/task, median cost/task (or unknown), success rate. See "Real task cost" below. |
In the TUI, /routing opens a list+detail modal: the list side shows every
category's current resolution (with a not-connected/unavailable fallback
notice inline, an auto (derived from …) marker for a category nobody
configured, and an unapproved-project notice when relevant); selecting one
opens a flat searchable model picker — one list spanning every connected
provider's models (built on the same type-to-filter machinery /model uses),
never a "pick a provider first" step — with a "provider default" row per
connected provider and a "session default" row. Each model row also shows its
profile (tier, price, context, priority, each field's source), and an
available: false model is marked UNAVAILABLE (still selectable). A
confirmed pick writes immediately to the per-user layer. t on the list side
shows the project file's entries and arms an approval; y confirms, any
other key cancels.
Model profiles and the derived default table (flow 327)¶
Keryx records what it honestly knows about each connected model — strength
tier, input/output price per million tokens, and context length — in a
per-user model profile catalogue (docs/requirements/keryx-jev-router/
PRD.md §6), keyed by <provider>/<model>. Every field carries its own
source, in the order each is tried:
reported— read directly off a provider's live/modelsresponse, when it carriespricing/context_length(confirmed for OpenRouter; parsed generically for any gateway that returns the same shape).curated— a small, hand-maintained seed table for the three providers with no live/modelsendpoint at all:anthropic,openai,gemini. Shows up from the FIRST read of the catalogue, before anything is written.guessed— strength tier only, from the same name-pattern heuristickeryx review tieralready uses (nano/mini/haiku/… →light,opus/pro/large/… →deep) — never used for price.operator— set bykeryx routing profile setor/routing's picker. Never overwritten by a later refresh.unknown— a price/context field with no reported/curated/operator value. Never0, never a fabricated number.
A profile is refreshed — diffed against what's already stored, never
wholesale replaced — every time a provider's live model list is fetched: at
keryx shell startup, on keryx providers status --refresh, and on a
/connect Test/keryx providers test (whose result then mentions the
diff, e.g. ok — 12 model(s) (profiles: 3 added, 1 changed, 1 now
unavailable)). A model no longer listed is marked available: false and
KEPT, never deleted; a routing entry that resolves to an unavailable model is
treated as unresolved at that layer and falls through, shown as:
Storage. The profile catalogue lives in its own file,
model-profiles.json, in keryx's per-user config directory (next to
auth.json and provider-catalog.json) — mode 0600, written atomically
under a file lock, never inside auth.json: that file holds credentials,
and a catalogue that grows to hundreds of entries has no business sharing it.
Every read-modify-write (a live refresh, providers test, routing profile
set) serializes on that lock, so two providers refreshing at once — the
common case at keryx shell startup — never lose one's update to the
other's. An existing auth.json's legacy modelProfiles field (from an
earlier release) is migrated into model-profiles.json once, automatically,
on the next write, and then removed from auth.json.
Non-chat models are never auto-derived. An embedding, image,
text-to-speech/speech-to-text, moderation or rerank model — detected by id
pattern, and by a gateway's own /models modality metadata when it carries
one (confirmed for OpenRouter's architecture.output_modalities/
architecture.modality) — and an OpenRouter :free variant are excluded
from derivation entirely, even when one would otherwise be the strongest or
only candidate. Both are still listed in /routing's flat picker and can be
picked manually — marked non-chat/free — never auto-derived there and in
keryx routing profile list.
Derived default routing, anchored on the SESSION's own model. When a category has nothing configured at any layer, keryx derives a sensible default from the session's own connected provider's models and their profiles:
- One available chat model becomes the default for every category (except
default/coding). - With several models, ranked first by family size class
(
opus/sonnet/haiku-style hints — flagship > mini/flash/lite — the same size-word tablekeryx review tieruses) and, WITHIN the same family and vendor, by the version number parsed from the id — both a dotted spelling (opus-5.5>opus-4.7,gemini-3.8-flash>gemini-3.1-flash) and a real Anthropic-style HYPHENATED one, where a run of 2-3 adjacent short (1-2 digit) numeric tokens reads as a dotted version (claude-opus-4-8→4.8, soclaude-opus-5-1>claude-opus-4-8). Parsed conservatively: a date/snapshot-shaped token (6-8 bare digits, e.g. a-20250514suffix) and more than one such numeric group in the same id both still yield no version rather than a guess.gpt-4o-style ids (a short number plus one trailing letter) parse the number alone as the version, with the letter kept only as an ignored variant tag — sogpt-4oandgpt-4.1land in the same family andgpt-4.1correctly outranksgpt-4o, a deliberate, documented choice rather than refusing that pair's comparison outright. A parameter-size token (7b,32b,70b,1.5b,8x7b— a total or MoE "N experts x M billion" param count) is NEVER a version — it always stays in the family key instead, soqwen2.5-coder-7b/qwen2.5-coder-32bandllama-3.3-70b/llama-3.3-8bkey to different families and are never version-compared against each other (only the one real vendor shape that needs it,4o's trailingo, is ever read as a version-plus-variant-tag; every other trailing letter after a short digit run is a size suffix). A trailing-latest/-previewalias word is also dropped from the family key alone (never from the id itself), soclaude-3-7-sonnet-latestjoins the same family asclaude-sonnet-5. Two versions of the same family are NEVER decided by alphabetical order — the id-string fallback only ever applies when a version is unparseable on both sides: planning/reviewget the strongest model not weaker than the session model — the session model itself when nothing is stronger.subagents/docs/unattendedget the next size step DOWN from the session's own family (an Opus session gives Sonnet) — never the smallest class, the session model itself when there is no middle step.quickgets the smallest class present (haiku/flash/mini-style).default/codingstay the session's own model, unchanged.- Ties are broken toward the session model itself, then by
priority.value.
keryx routing list shows a derived category as
auto (derived from <provider>'s models) -> ….
Real task cost (flow 341)¶
The step-down rule above assumes a lighter model is cheaper per task. That is not always true: a lighter model can burn more tokens finishing the same task than a stronger one would have, so per-token price alone is not task cost. Keryx measures the real thing instead.
Every finished keryx shell turn records one entry — provider, model,
routing category (when assigned), input/output tokens, cost in USD (when the
model's price is known), and whether the turn finished cleanly — into a
rolling per-user store (task-cost.json, mode 0600, atomically written,
capped at 200 recent samples per (provider, model, category) key). Nothing
in the store is a credential or a key.
keryx routing stats [--json] prints the rolling summary: sample count n,
median tokens/task, median cost/task (unknown when no sample in the window
has a known price), and success rate, one row per (provider, model,
category), sorted by provider then model then category.
That summary feeds back into derivation: for subagents/docs/unattended,
once BOTH the structural step-down candidate and the stronger candidate have
at least 20 measured tasks in that category AND both have a known median cost
per task, the step-down pick is used ONLY IF its own median cost per task is
strictly lower — otherwise the stronger candidate is used instead. Below that
evidence bar (too few samples, or cost unknown on either side), the
structural, size-based rule decides exactly as before. /routing's list
shows the per-task cost next to a derived row once it is known:
auto (derived from anthropic's models) -> anthropic/claude-sonnet-5 · ~$0.04/task (n=37).
external¶
Flow 346 — the EXTERNAL switch: keeps private work (code, diffs, CI-log
excerpts, prompts, rule text) in-house by blocking every call to
Jev/TypeSafe and any other listed provider/model before any network I/O.
Two config layers, same "project wins" precedence every other project-vs-
user keryx toggle uses: per-project .metaproject/tasks.config.json
(external) > per-user setting (shell config) > built-in default
("on").
On by default, off when you say so. external: "on" (the default, and
the pre-flow-346 behavior byte-for-byte) sends private work to Jev/TypeSafe
and any connected provider/model exactly as before. external: "off"
blocks every destination listed in <keryx config dir>/external-
providers.json — the Jev client (callJevSystemOne, every review-jev-*
command, conform, ci-triage, jev-select, the routing classifier, the
turn guard) and the routing/model-selection choke point both check it, so a
listed provider/model is excluded from routing (falls through to the next
layer, with a notice — never silently) and every Jev call throws a typed,
readable ExternalBlockedError instead of ever reaching fetch.
The block list is yours to edit. <keryx config dir>/external-
providers.json is created with built-in defaults on first use and never
overwritten again — keryx external list prints the effective list and
where it came from. The built-in defaults: Jev/TypeSafe System One (the
OpenRouter /api/v1/systemone endpoint — it receives code and CI logs on
every call); keryx's own direct provider ids for vendors hosted under
jurisdictions/terms where a prompt may be retained or trained on
(deepseek, zai/zai-coding, moonshot); the SAME vendors reached
instead through an OpenRouter model id, plus vendors with no first-class
keryx provider at all (deepseek/*, minimax/*, z-ai/*/zhipu/*/glm/*,
moonshotai/*, qwen/*/alibaba/*, baidu/*, tencent/*,
bytedance/*, 01-ai/*); OpenRouter's free tier (*:free), whose
underlying provider's logging/training policy is unaudited per-model; and
any model id containing muse, named explicitly by the operator. The
mainstream paid US providers you connect directly — Anthropic, OpenAI,
Google/Gemini, GitHub Copilot, xAI, Groq — stay off the list.
Jev is on by default wherever it is reachable. The three recommended,
fail-open review.jev.* steps (ci_triage, select, edit_guard) now
apply automatically — no per-project opt-in — whenever /external is on
AND a Jev/OpenRouter credential resolves (env or a saved key). An explicit
true/false for that key in .metaproject/tasks.config.json always wins
over the default. keryx review jev-profile show names the source of each
key's effective value: explicit, default-because-jev-available, or
off-by-external. The first time a project actually sends data because of
this default (not an explicit opt-in), one line is shown once — on CLI
stderr for ci_triage/select/the edit-guard hook — and recorded so it
never repeats for that project: Jev is on here: redacted code/CI snippets
go to OpenRouter/TypeSafe. Turn off: /external off.
keryx external on [--project]
keryx external off [--project]
keryx external status [--json]
keryx external list [--json]
| Subcommand | Flags | Description |
|---|---|---|
on / off |
--project |
Persist the setting per-user (default) or, with --project, to this project's own .metaproject/tasks.config.json override. |
status |
--json |
The effective on/off state, which layer answered (project/user/default), whether a Jev credential resolves, and what is blocked right now. |
list |
--json |
The effective block list — every provider id and model-id pattern, each with its reason — and where the list came from (default-created / user-file / default-fallback-malformed, with a warning). |
In the TUI, /external alone prints the same status block (state, source,
credential availability, effective block list); /external on|off toggles
+ persists the per-user setting. A sidebar row appears when it is off —
the default on state costs no permanent sidebar space, the same idiom
/guard//route use for their own default state.
serve¶
An opt-in, off-by-default loopback-bound HTTP entry over the same agent
harness keryx shell drives. It exists so a bot, a browser workspace, or a
third-party embedding becomes a client of one surface rather than a second
agent runtime with its own copy of session state.
keryx serve [--bind <addr>] [--port <n>] [--profile <name>] [--acknowledge-non-loopback]
keryx serve status [--json]
keryx serve token issue | rotate | revoke
keryx serve config init | set | show
| Subcommand | Flags | Description |
|---|---|---|
| (no argument) | --bind <addr>, --port <n>, --profile <name>, --acknowledge-non-loopback |
Bind and listen. Defaults to loopback; a non-loopback bind requires --acknowledge-non-loopback and is never the default. --profile selects the remote policy profile. |
status |
--json |
Report state. Configuration state only — there is no PID file, so a listener in another process is not visible here; listening and draining are knowable only over the authenticated GET /v1/status. |
token issue \| rotate \| revoke |
— | Bearer-token lifecycle. Only a salted hash is persisted; the plaintext token is printed once, at issue, and cannot be recovered afterwards. |
config init \| set \| show |
— | Create, modify, and print the listener configuration. Without a config, serve does not listen. |
Routes¶
The routes below take the serve bearer token. The /v1/remote/* routes, listed
after them, take a different secret and are not reachable with it.
| Route | Description |
|---|---|
GET /v1/status |
Listener state, authenticated. |
GET /v1/projects |
The projects this listener will accept turns for. |
POST /v1/turns |
Submit a turn. Takes an idempotency key scoped per project, so two projects cannot collide on one key. |
GET /v1/turns/<id> |
The durable turn record, and its server-sent-event stream. |
GET /v1/approvals |
Pending approvals (?state=all adds recently resolved). See Approvals over serve. |
POST /v1/approvals/<id> |
Answer one approval with {"decision":"allow"} or {"decision":"deny"}. |
Remote control routes, shell token only. These eight exist only when remote
control is configured (see below), are reached by a running keryx shell over a
loopback connection, and accept the local shell token — a separate secret serve
creates under remote/ in the user-global directory — and not the serve
bearer token. A caller presenting the wrong one of the two gets the same 404 as
for a path that does not exist. Bodies are limited to 64 KiB.
The shell token is minted fresh every time serve starts, after the poller
lock is taken, and the shell re-reads it on every request, so a restart of serve
needs no action in the shell. A shell does not send it to an endpoint whose
recorded serve process is no longer alive: it reports that remote control is
unreachable instead. A serve that lost the poller lock to another one mints no
token, and answers 401 to these routes.
serve proves its identity. The shell sends ksp1.<nonce>.<HMAC> (a fresh
nonce and an HMAC of it with the token) instead of the raw token, and serve signs
each answer in the x-keryx-serve-proof header over the nonce, route, status and
body. A missing or wrong proof (a different process on the port, a tampered body, a
replayed answer, a serve older than this check) fails with code unverified-serve
and the message "restart keryx serve"; nothing from that answer is written. The
raw token from older shells is still accepted by serve.
| Route | Description |
|---|---|
POST /v1/remote/register |
A shell asks for a topic for its session. Idempotent per session. |
POST /v1/remote/deregister |
The shell stops remote control; the topic gets a closing note and is archived (closed, still readable), not deleted. |
POST /v1/remote/heartbeat |
Keeps the session alive; a heartbeat from an unregistered session is refused with a reason. |
POST /v1/remote/reply |
Text for the topic. Replies over 4096 characters are split. |
POST /v1/remote/approval |
Raises an approval question in the topic, with Allow and Deny buttons. |
POST /v1/remote/approval-ack |
The shell says what it did with the answer to an approval ({sessionId, approvalId, applied?}). serve waits 5 seconds (approvalAckMs) after a button press: an ack in time edits the message to "Allowed by ..." or "Denied by ..." when applied is true, to "Not applied ..." when it is false (the shell had no question waiting for that id), and none leaves it saying the answer was sent to the shell, not confirmed. An ack without applied (a shell older than the field) means received only and is treated as applied, so an older shell over-reports Allowed; the ack names its session in the body and is authorised by the shared shell token, like the other shell routes; a non-boolean applied is a 400. An ack that arrives later is accepted and changes nothing; an unknown id, or one of another session, is a 404. |
POST /v1/remote/prompt |
Raises a question with option buttons in the topic ({sessionId, text, rows, timeoutMs?, forUserId?, own?}). With own: true, serve adds a last button ✍ Свой ответ; pressing it posts a ForceReply message, and a reply to that message by the same user answers this exact question. The own window is at least 5 minutes, the option buttons keep timeoutMs. |
POST /v1/remote/prompt-close |
The shell closes a question it no longer needs ({sessionId, promptId, by?}): by: "shell" when the dock answered first, otherwise it reads as cancelled. Its buttons and its reply box stop working. The answer is {closed: true}, or {closed: false} for an id that is unknown, already answered, or another session's (nothing is changed then). |
POST /v1/remote/ack |
The shell confirms it took a delivered line. |
GET /v1/remote/stream?sessionId=<id> |
Server-sent events: the lines typed in the topic, and button answers, for that session. |
Remote control from Telegram¶
Configuration for the Telegram transport inside keryx serve. It is off unless
both files exist and validate; with neither of them serve prints
channels ready: connect Telegram from the shell with /channels and behaves exactly
as before. remote control is off: <reason> is printed only for an invalid config
or a start that was refused. Both live under
remote/ in the user-global keryx directory (~/.local/share/keryx/ on
Linux and macOS, %APPDATA%\keryx on Windows):
remote/bot-token— the token of a separate BotFather bot, on one line, mode- A file readable by group or others is refused, naming
chmod 600. remote/config.json— a closed schema; an unknown key is an error.
| Field | Type | Meaning |
|---|---|---|
schemaVersion |
1 |
Required. |
chatId |
integer, not 0 | Required. The supergroup (topics enabled, bot an admin with manage topics) the session topics are created in. |
allowedUserIds |
integer array | Required, non-empty. Telegram user ids whose messages and button presses are accepted; anyone else is ignored and their id and time are appended to remote/rejected.jsonl. |
orphanMs |
integer, 1 to 604800000 | Optional, default 600000 (10 minutes). How long a topic is kept after its session stops sending heartbeats, before it is deleted. A heartbeat inside the window cancels the deletion. |
runTimeoutMs |
integer, 0 to 604800000 | Optional, default none. 0 or absent: a run started from Telegram has no time limit (/stop in the topic ends it). A positive number: how long it may take before the shell interrupts it and tells the topic. |
permissionMode |
"ask" or "trust" |
Optional, default trust. The default permission mode of a turn started from Telegram. Applies until /mode changes the mode in that shell session; after that the shell's mode wins. auto is never read from here. |
approvalTimeoutMs |
integer, 30000 to 3600000 | Optional, default 900000 (15 minutes). How long an approval question in the topic waits before the command is refused. Telegram only: the HTTP approval expiry in serve.json is not changed by it. |
To return to the previous behaviour, set "permissionMode": "ask",
"runTimeoutMs": 1800000 and "approvalTimeoutMs": 300000. /remote-policy
[mode ask|trust] [limit none|<minutes>] [wait <minutes>] in the shell edits these
three keys and the running shell's copy; with no argument it prints them, the mode in
force (trust (Telegram default) or ask (shell /mode)) and the number of saved
rules. keryx serve status and --json print the same posture (the number of allowed
users, never their ids) and say when the shell's /mode overrides the default. The
Telegram group of /settings and the sidebar posture line (a click opens
/permissions) show it in the full-screen shell.
The transport is the single Telegram poller in keryx serve; a second serve on
the same token stops on Telegram's conflict answer and does not poll again. Turn
it on per session from the shell with /remote-control, and
connect the bot from the shell with /channels instead of writing the two files. The
walk-through, including what is not yet verified against real Telegram, is in
Drive keryx remotely.
Delivery and limits worth knowing:
- No offset is persisted. After a restart the poller asks Telegram for what is
still unconfirmed, and
servedrops an update whose exact id it has already seen (a bounded recent set). An id that is lower than ones already seen is not treated as old: Telegram can restart its numbering, so such a batch is delivered and a poller-status event records the reset. - At most 500 undelivered lines wait per topic. Past that a line is dropped and the topic gets one status line saying how many.
- Turning remote control off from the shell tells the topic which queued
Telegram lines will not run, then deletes it. Removing one queued Telegram line
with
/queuetells its topic as well. A line edited with/queue editkeeps its Telegram origin, so its answer still goes to the topic. - Only the final answer is sent. Text a model writes before a tool call is narration and stays in the shell; the topic gets the text of the round that ended without one.
- Button presses are acknowledged in the background; a slow Telegram does not hold up the next update.
Boundaries¶
These are properties of the implementation, not advice:
- Authentication runs before routing. An unauthenticated caller cannot distinguish a known path from an unknown one — both answer identically.
- Bearer tokens are compared in constant time.
refusedbinds no socket at all. It is a terminal state, never a degraded listen: a listener that cannot satisfy its configuration does not open a port.- The remote policy profile may never be weaker than the local one. It is compared per turn, and a weaker profile is refused rather than accepted.
- Approvals are asynchronous and bound to one call. A turn whose policy
decision is
askcreates a durable pending approval, emitsapproval.pending, and waits. It denies at expiry, when too many are pending, or at once when nobody could answer. An allow applies to that one fingerprinted call, once, and never lifts the destructive, credentials, publish-lease, untrusted-origin or hook-ask floors. See Approvals over serve. - Repeated authentication failures are throttled, per peer.
GET /healthdoes not exist. Liveness is authenticated-only today.
approvals¶
Answer, from this machine, a call a keryx serve turn is waiting on. It reads
and writes the same durable approval store the HTTP routes use, so it needs no
running server.
| Subcommand | Flags | Description |
|---|---|---|
list (also the bare default) |
--all, --json |
Pending approvals with summary, scope, consequence and time to expiry. --all adds the recently resolved ones. --json prints the public projection (never the call fingerprint). |
allow <id> |
— | Allow the one call the approval was raised for, once. A second answer reports the original outcome and changes nothing. An expired approval is refused. |
deny <id> |
— | Deny it. |
An answer never grants a session-wide trust and never lifts a destructive,
credential or publish floor: the next identical call asks again. In the TUI the
same list is /approvals. The client contract for a chat bridge is
Approvals over serve.
permissions¶
The saved shell rules: what an Always answer remembered, in the shell or from
Telegram. Reads and edits the same permissions.json the shell approver reads on
every call, so it needs no running shell.
| Subcommand | Flags | Description |
|---|---|---|
list (also the bare default) |
--json |
The rules that run without asking, then the ones the validators no longer honour (kept in the file, with the reason), numbered. --json prints path and rules[] with n, pattern, honoured and reason. |
remove <number\|pattern> |
— | Take one rule back by the number list prints, or by its exact text. Other rules are left exactly as they were. The command asks again from then on. |
A rule can only be removed here or in the shell, never from a chat, and nothing in
this verb adds one. A running shell stops using a removed rule on its next
approval and, when the removal came from another process, shows its tamper warning
for the changed file. In the shell and the TUI the same list is /permissions
(/permissions remove <number|pattern>), and the TUI opens it as a modal.
decisions¶
The recommendation journal: what an agent recommended when it asked a question with options, how it was shown, what you chose and how long you took. See Keep a record of what an agent recommended.
keryx decisions open --question "<text>" --option <id>=<label> [--option ...] [--recommend <id> --reason "<why>"] [--stage <name>] [--flow <id>] [--action <tag>] [--channel <name>] [--json]
keryx decisions answer <id> --choice <id> [--other] [--reason "<why>"] [--json]
keryx decisions reason <id> --text "<why>" [--json]
keryx decisions report [--json | --line] [--exclude-legacy]
keryx decisions import <file.jsonl> [--dry-run] [--json]
keryx decisions rate <id> good|bad|unclear [--note "<why>"] [--json]
keryx decisions rate --blind-model --model "<label>" --model-cmd "<command>" [--since <date>] [--id <id>] [--limit <n>] [--json]
keryx decisions export [--since <date>] [--format jsonl|json] [--exclude-legacy]
| Subcommand | Flags | Description |
|---|---|---|
open |
--question, --option (repeatable), --options-json, --recommend, --reason, --stage, --flow, --action, --channel, --json |
Writes the record BEFORE the question is shown and prints the mode (ordinary or blind, one in three), the order to show and whether to mark the recommendation. A question about something on the irreversible list is never blind: strong terms (release, ship, rollout, promote, publish, unpublish, deploy, delete, push, git reset --hard, rm -rf, tagging a version, publishing to npm, going to production, the Russian equivalents, plus .metaproject/decisions.config.json) always count; weak terms (merge, drop, remove, force and the like) count next to a risk target (main, production, a branch, a PR, a table, ...) in the same question or option, or in --action, or when their object is only a pronoun ("Merge it now?"). Pass --action for anything irreversible: any non-empty tag makes the question non-blind, and it is the reliable path. A flow found only as the single flow in progress is recorded as inferred. |
answer <id> |
--choice, --other, --reason, --json |
Records the choice (it must be one of the options; --other marks a free-form answer) and prints the reveal and the time taken. A second answer is a changed answer and both are kept. After a deviation it says the human should be asked for a reason, once; the command does not wait, the caller asks (the TUI ask_user path waits for it, and an empty answer releases the wait). |
reason <id> |
--text, --json |
Records the reason for a deviation; an empty text is recorded as absent. |
report |
--json, --line, --exclude-legacy |
Deterministic, no model: match share by mode, by arm (arm A as "A free" and "A forced", the latter not randomized), by channel (on telegram arms A and B are one "A+B" row) and by stage; the records from before the arms in a "Legacy" block of their own, left out of every number by --exclude-legacy; the recommendation-quality matrix (model ratings labelled "model self-assessment"); every deviation with its reason, and how many questions looked irreversible and had blind refused because of it, per stage. Backfilled decisions are reported in a block of their own ("до (историческое, дозаполнено задним числом)": total, with a recommendation, matches and share, every deviation with its reason) and never counted in the live numbers or in the median time to answer; --json has a backfilled section. --line prints one line in Russian for a daily message: Журнал решений: всего N (до: B, после: L). Совпадение с рекомендацией: видимая X% (a/b), скрытая Y% (c/d); до: Z% (e/f). (нет данных when a share has nothing to count). |
import <file.jsonl> |
--dry-run, --json |
Loads historical decisions as backfilled, legacy: true records, one JSON object per line (id, at, flow, stage, question, options, recommendation, source, answer, reason, and optionally "mode":"blind"). An ordinary record goes to arm A and a blind one to arm D, but neither was randomized. A backfilled decision keeps the time it was asked in at, and its time to answer is unknown. A line that is not a decision is skipped, named with its line number and why, and counted; the rest is imported. An id already in the journal is skipped and reported, so importing the same file twice changes nothing, except that a backfilled decision whose answer is missing (an interrupted write) gets just that answer. Prints Imported: N, skipped: S, with recommendation: R, answered: A, deviations: D, plus , repaired: R and , malformed: M when there are any; --dry-run counts without writing; with --json a failure is {"error": "..."}. |
rate <id> good\|bad\|unclear |
--note, --json |
The human's rating of a closed decision's recommendation, the main measure. Appended to .metaproject/data/decisions/quality.jsonl. |
rate --blind-model |
--model, --model-cmd, --since, --id, --limit, --json |
Has a model rate every closed decision that has a recommendation, in a clean context: the question and the options as the human saw them, no mark, reason, choice or history. --model-cmd reads the prompt on stdin and prints the answer; each call is a new process. cleanContext is true only when the call carried no history. Always labelled "model self-assessment": the model may have written the recommendation. A decision already rated under the same label is skipped. |
export |
--since, --format jsonl\|json, --exclude-legacy |
One row per decision with structure only: arm, seed, preselected, forced, legacy, channel, order as option positions, recommended and chosen position, deviation, ratings, times. No question, option, reason or note; a hashed decision reference. |
--flow and --stage default to what the checkout says: KERYX_FLOW, else the
flow named by the git branch, else the only flow in progress (a guess, marked inferred in the record and the report). The journal is
.metaproject/data/decisions/journal.jsonl under the main checkout, shared by every
worktree; inside a flow the answer also adds a line to that flow's journal.md.
In the TUI the report is /decisions, /decisions reason <why> adds the optional
reason and /decisions change <option> changes the latest answer asked in this
session (a decision of another session is named and left alone). Backfilled
decisions are never "the latest": a bare /decisions change skips them, and the
/decisions report and the sidebar row show them apart (N decisions + B before).
research¶
Keeps the materials behind part 1 of the research article current. The catalog is
docs/research/role-blurring-part1/; its snapshot files (part1-counts.json,
decisions-export-2026-10-04.jsonl) stay as they are, and the -latest files
next to them are refreshed by the command.
| Subcommand | Description |
|---|---|
sync |
Run from the repository root. Writes part1-counts-latest.json (the unchanged part1-counts.py over the flows committed at HEAD), decisions-export-latest.jsonl (the text-free decisions export from 2026-10-02 on, the same as keryx decisions export) and sync-status.md (the UTC time of the run, the HEAD hash, the counts of the snapshot next to the counts now, the journal summary and the number of rows in contribution-log.md, and a "verification kinds" line: the share of unclassified criteria among the flows frozen in the last 7 days, with a warning above 20%, and a "sensitivity" line: the share of exec and invariant criteria carrying a sensitivity note among the same flows, with no threshold and no warning). A file is rewritten only when its content changed, so a second run with no new data changes only the run time in sync-status.md. It makes no commit, no branch and no pull request. Takes no arguments. When it fails, the previous -latest files are left as they were and the reason is written into sync-status.md; the exit code is 1. |
--help, -h |
Print research usage and exit. |
The daily run is a scheduler entry: when the keryx serve scheduler is on and the
entry is enabled, the same command runs once a day (see schedule). The
date and time of the last run is the Запуск (run, UTC) line of sync-status.md;
in the TUI it is the sidebar row "Материалы части 1" (shown only in a checkout that has the
catalog; "ещё не запускалась" before the first run).
update¶
Refresh managed "service" files (templates, manifests, skills, hooks, dashboard)
to match the current runtime, without ever writing under .metaproject/data/.
Also self-updates the runtime it was launched from and backfills newly added
modules. Errors with exit code 1 if .metaproject/ does not exist.
| Flag | Description |
|---|---|
--skip-runtime |
Skip the git fetch/checkout that self-updates the vendored runtime. |
--hooks |
After refreshing, run every executable in .metaproject/hooks/post-update.d. Without it, a hint is printed instead. |
--no-tasks |
Do not auto-enable (backfill) the tasks/flow module on pre-tasks workspaces. |
--help, -h |
Print update usage and exit. |
update also moves keryx's own edits out of tracked files. A legacy
agentEntrypoints.root string array is rewritten to the entry form; a managed
block or keryx-managed hooks that exist only as uncommitted edits in AGENTS.md,
CLAUDE.md, .gitignore or .claude/settings.json move to CLAUDE.local.md,
AGENTS.override.md, .git/info/exclude and .claude/settings.local.json, and
each file is restored to HEAD (or, when it has other uncommitted edits, only
keryx's part is removed). One that is committed in HEAD stays shared, and the
output prints how to switch it. Each move prints one line naming the file. A
second run changes nothing. Details:
migration.
sync¶
Reconcile the derived layers — graph, wiki, memory — with the current code. Each
artifact records the commit it was built from; sync diffs that commit against
HEAD and reports exactly what changed, or updates the artifact incrementally and
advances its provenance. This is what keeps a git pull or a branch switch from
leaving the agent reading a stale map.
keryx sync # report added / changed / deleted since each artifact was built
keryx sync --apply # update the artifacts incrementally and advance provenance
keryx sync install-hooks # run sync on git pull (post-merge) and branch switch (post-checkout)
keryx sync uninstall-hooks
| Subcommand / flag | Description |
|---|---|
| (none) | Advisory report per module. Prints HEAD, then per artifact either "up to date" or the change counts with the first few paths. Always exits 0 — the hooks decide what to do with the report. |
--apply |
Rebuild each stale artifact incrementally and record the new provenance. An artifact with no provenance yet is built as a baseline. Takes the project's maintenance lock (see trigger → Locking): when another keryx run holds it, waits up to KERYX_MAINTENANCE_LOCK_WAIT_MS (default 120 s), then exits 75 naming the holder's pid. |
install-hooks |
Install post-merge and post-checkout git hooks that run the advisory report. Prints that nothing was installed when there is no .git. |
uninstall-hooks |
Remove them. |
--help, -h |
Print sync usage and exit. |
Outside a git repository the command reports that there is nothing to sync and
returns. When --apply updates the wiki and files were deleted, orphaned pages
are pruned; a human-owned page whose module disappeared is reported rather than
deleted.
trigger¶
Fire one declared project trigger — the single entry point every git hook, cron
line, systemd timer and CI job calls. A trigger is declared by hand in
.metaproject/triggers.json (keryx never writes this file — see
sync and update for the files it does own), each entry
naming what fires it (a repository event or a schedule) and what it does
(reconcile, rebuild, open-flow, or flow-next). Scheduled agent tasks
(agent-task) are the one kind keryx writes. It writes them only after you
confirm, and into a separate per-machine store, never into triggers.json. See
schedule.
The config file¶
.metaproject/triggers.json:
{
"schemaVersion": 1,
"triggers": [
{ "name": "reconcile-on-merge", "on": { "kind": "event", "event": "post-merge" },
"action": { "kind": "reconcile" }, "enabled": true },
{ "name": "rebuild-on-commit", "on": { "kind": "event", "event": "post-commit" },
"action": { "kind": "rebuild" } },
{ "name": "nightly-maintenance", "on": { "kind": "schedule", "cron": "0 2 * * *" },
"action": { "kind": "open-flow", "template": "Nightly graph/wiki maintenance", "skipIfOpen": true } },
{ "name": "report-next-task", "on": { "kind": "event", "event": "ci" },
"action": { "kind": "flow-next", "flow": "142-2026-08-01-nightly-maintenance" } },
{ "name": "overnight-next-task", "on": { "kind": "schedule", "cron": "0 1 * * *" },
"action": { "kind": "flow-next", "flow": "142",
"dispatch": { "provider": "anthropic", "model": "claude-sonnet-4-5", "permissionMode": "trust",
"rates": { "inputUsdPerMTok": 3, "outputUsdPerMTok": 15 },
"ceilingUsd": 2, "maxSeconds": 1800, "maxAttempts": 3 } } }
]
}
Each entry: name (unique, also the trigger run <name> argument — letters,
digits, -, _, ., max 64 chars), on ({ kind: "event", event:
"post-merge" | "post-commit" | "post-checkout" | "ci" } or { kind:
"schedule", cron: "<5- or 6-field cron expression>" }), action, and
enabled (defaults to true). "ci" is declared, not hooked — nothing writes
a git hook for it; it exists so an entry can say "a CI job calls keryx
trigger run for this itself" and show up in list/status.
Action kinds:
{ "kind": "reconcile" }— one pass ofkeryx sync --apply.{ "kind": "rebuild" }— one pass ofkeryx gdgraph build.{ "kind": "open-flow", "template": "<flow title>", "skipIfOpen"?: true }—keryx flow init --title "<template>". WithskipIfOpen, a fire is a no-op when another flow's title is exactlytemplateand that flow's status is notdone(aninitializing, not-yet-started flow still counts as open).{ "kind": "flow-next", "flow": "<flow id>" }— report-only: recordskeryx flow next <flow>'s decision into the run record.list/statusshow it asflow-next(<flow>, report-only).{ "kind": "flow-next", "flow": "<flow id>", "dispatch": { … } }— dispatches a keryx agent to work the flow's next task, unattended. See Dispatchingflow-nextbelow.{ "kind": "agent-task", … }: a free-form scheduled agent task that leaves a report. It is created only withkeryx schedule addor/scheduleand lives in.metaproject/data/trigger/schedules.json. Anagent-taskwritten intotriggers.jsonis refused on load. See schedule.
An entry that fails validation is refused on load with the reason, and its
neighbours still load; a later entry re-using an already-used name is
refused the same way. keryx trigger list prints every rejected entry
alongside the valid ones.
Subcommands¶
keryx trigger run <name> # perform exactly one pass of <name>'s action
keryx trigger install # write a git hook block for every event-fired entry
keryx trigger uninstall # remove those hook blocks
keryx trigger list # list declared entries: enabled state, fire, action, hook status
keryx trigger status [<name>] # show the last recorded outcome for one or every entry
keryx trigger schedule <name> # print the cron line / systemd timer unit (real OnCalendar=) for a schedule entry
| Subcommand | Description |
|---|---|
run <name> |
Resolve <name> against .metaproject/triggers.json and perform exactly one pass of its action. |
install |
Write a managed hook block (# keryx:trigger-<name>:begin/end) into post-merge/post-commit/post-checkout for every declared, event-fired entry — enabled or not, so re-enabling one later takes effect on its next fire without reinstalling. No .git directory: prints and does nothing. |
uninstall |
Remove those blocks. Every other managed block in the same hook file is untouched. |
list |
List every declared entry — name, enabled state, what fires it, what it does, and whether its hook is installed — plus every rejected entry and why it was refused. |
status [<name>] |
Print the last recorded outcome for one entry, or every entry, read from the run record (below). |
schedule <name> |
Print the cron line and the systemd service/timer pair for a schedule-fired entry, with a real OnCalendar= translated from the cron and a project-unique unit name. Installs nothing. keryx schedule add installs a timer for you. |
--help, -h |
Print trigger usage and exit. |
Exit codes. Non-zero only when the action itself failed (for a dispatch:
the task did not end done), the name is unknown, or the matching entry is
malformed. Every other outcome exits 0: nothing declared, a disabled entry,
a lock refusal, a budget refusal and a dispatch refusal are all "nothing done
this pass", not an error — which is also how each is classified in the run
record.
Dispatching flow-next¶
A dispatch block turns flow-next from a report into real work: one fire
starts one keryx agent on the flow's next ready task, with no terminal and
nobody present, and records what happened.
| Field | Required | Meaning |
|---|---|---|
provider, model |
yes | The model the agent runs on. Only providers known to report token usage on every response are accepted: anthropic, openai, gemini, and an OpenAI-compatible provider whose registry entry sets streamUsage (today: grok and deepseek). Anything else is refused (provider-usage-unknown). A provider with no usable credential is refused before anything is written. |
permissionMode |
no (ask) |
ask — every non-read call is denied (read-only by construction). trust — non-destructive shell_exec/apply_patch run, inside the hardened sandbox, which is then mandatory. auto is rejected at load. |
rates |
yes | { inputUsdPerMTok, outputUsdPerMTok } — USD per million tokens, both greater than zero. Without rates, or with a zero rate, a run is free to the ceiling; the entry is rejected at load. |
ceilingUsd |
yes | This trigger's own spend ceiling, on top of the project-wide one. Rejected at load when absent. |
maxSeconds |
no (1800) | Wall-clock limit for one agent run. |
maxAttempts |
no (3) | A task whose attempt count has reached this is not dispatched again. |
baseUrl |
no | Provider base URL override — loopback only (localhost, 127.0.0.0/8, [::1]). triggers.json is a committed file and the run sends the operator's saved key for provider to this URL, so a non-loopback URL is rejected at load. |
network |
no (false) |
Network for the agent's shell commands. true gives them the host's full network: the internet, every service on the host's loopback (a local model server, a database, …) and the host's abstract unix sockets — nothing is filtered, and keryx trigger list says so for the entry. The model call is made by the dispatcher itself, outside the sandbox, so talking to the provider — including a local Ollama on 127.0.0.1 — never needs this. Leave it off unless the task's own commands truly need the network (a loopback-only mode via slirp4netns --disable-host-loopback is possible but not built in this version). |
One fire, in order — every refusal happens before any model call:
- A per-flow dispatch lock: a second dispatch on the same flow refuses
(
dispatch-refused,dispatch-locked); different flows do not wait on each other. - The flow must be
in-progresswith frozen acceptance criteria,flow nextmust beready, the ready task must have no open attempt, and its attempt count must be undermaxAttempts. Otherwisedispatch-refusedwith the cause (flow-not-in-progress,flow-not-frozen,nothing-ready,blocked,open-attempt,attempt-cap), exit0. - Containment:
trustrefuses (sandbox-unavailable, exit0, reason recorded) unless the hardened sandbox below can be built — no launcher, a launcher that cannot create namespaces, a non-Linux host,KERYX_DANGEROUSLY_DISABLE_SANDBOX=1orKERYX_SANDBOX_SHELL=offall refuse.askmay run without one: every command and patch is an approval request, every approval request is denied, and its shell runner refuses every command as well. - The spend reservation: under a project-wide spend lock, the remaining
allowance (the smaller of the trigger's and the project's) is written to the
ledger as a
reservedrecord before the first model call. At or over either ceiling, or with nothing left to reserve →budget-refused, exit0. Two triggers firing together cannot both get the full allowance. - A throwaway git worktree on branch
trigger/<flow>-<task>(reused when it exists). Never your checkout; the branch is never pushed. A worktree a killed run left registered to that branch is recovered first — removed when it is under the dispatcher's own worktree directory, pruned when its directory is gone; a branch checked out anywhere else refuses (worktree-conflict) and is never touched. flow task attempt <flow> <task> --outcome startedwith the run id — before the model is called.- One agent turn in the worktree (see Unattended posture), stopped by
maxSeconds, when its priced cost reaches the reservation, or when a response arrives without token usage (that run is charged its whole reservation). - Whatever changed is committed on the trigger branch; then
keryx health runandkeryx health gaterun in the worktree inside the same sandbox — they execute code the agent wrote. - Exactly one closing fact:
flow task done --disposition completedwith arunLinkonly when the turn ended normally, the branch has a new commit and the health gate passed; otherwiseflow task attempt --outcome failed|blockedwith the reason (blockedwhen the run was stopped by denials or byask_user). The worktree is removed; the branch stays for you to review and merge.
The unattended sandbox (Linux, bubblewrap). Every shell_exec of a
dispatched run, and its health gate, run inside a profile built from allow
lists: / read-only; your home directory and all of /run (and
/var/run where it is not a symlink to it) hidden behind an empty tmpfs.
/run is where the host's services listen — the system D-Bus, systemd-resolved,
tailscaled, libvirt, snapd, Docker, ssh-agent and gpg-agent under
/run/user/<uid> — and turning the network off does not isolate unix
path sockets, so the whole directory is hidden rather than known sockets masked
by name. Nothing under /run is bound back; with network: true only the
resolver file /etc/resolv.conf points to is bound back, read-only; bound back read-only only the toolchain roots found on PATH under
your home (~/.bun, an nvm node version — detected, not hard-coded), the
repository's git directory, the keryx package and node_modules; the worktree
and a scratch HOME read-write; a private /tmp; the Docker socket masked;
network off unless dispatch.network: true (abstract-namespace unix sockets
are per network namespace, so they are isolated with it). The environment is an allowlist —
PATH, locale, TERM, TZ, colour flags — with HOME, the XDG directories
and TMPDIR pointed at the scratch home: no exported token, no
SSH_AUTH_SOCK. The known-secret deny list (~/.ssh, ~/.config/gh, …) is
still applied inside anything bound back. macOS sandbox-exec is not
implemented for this profile, so trust refuses on macOS.
Unattended posture — fail closed. The agent runs with unattended: true.
Its permission mode is the entry's — never the project's stored default
(/mode), never the saved shell allowlist. Every call that would ask — a
write or command under ask, a destructive one under trust, anything
touching credentials or the SAC confirm flow, anything after untrusted
content, ask_user — is denied and written to the run record with the
tool and the reason; nothing is ever approved on your behalf. On top of the
mode, a text floor denies: git push (any form), git merge, git tag, git
update-ref, git branch -f|-D|-m|…, gh pr merge, gh release, gh api
with a mutating method or a body, npm publish, bun publish; keryx flow
freeze, flow start, flow ac update|reseal|confirm, flow implemented,
flow complete, flow renumber, flow block|unblock, flow task
add|depends|done|attempt|skip, keryx trigger run (no nested dispatch); and
any write — by apply_patch or by a command that names them — to
flow.json, acceptance-criteria.md, .metaproject/triggers.json or
.metaproject/data/trigger/**. The floor is defence in depth, not the
boundary: it is text matching, a shell can spell a command in ways it does
not see (quoting, $(…), aliases, bun -e/python3 -c), and it is
deliberately over-broad (a commit message containing "tag" is refused). The
boundary is the sandbox — with the network off and credentials hidden, a push
or an API call that slips past the text has nothing to authenticate with and
nowhere to go. The tool roster is get_cwd, list_dir, read_file (confined
to the worktree), shell_exec, apply_patch — no web_fetch, web_search,
search_tool/use_tool, spawn_subagent or ask_user.
Cost. Every dispatched run records cost: { recorded: true, usd, tokens:
{ input, output } } — tokens as the provider reported them, USD from the
entry's rates — including a run that failed, timed out, was stopped, or hit
an error while writing its closing fact. That record also closes the run's
reserved record. Both ceilings sum recorded costs plus every reservation
no record has closed yet, so a run in flight — or one whose process was
killed — keeps its whole reservation counted. keryx trigger status lists
open reservations; once you know the killed run is gone, close its
reservation with what it really spent (from your provider's console):
There is no default for --spent: guessing would be the fail-open this
exists to prevent.
The run record¶
Every run that resolves to a real, declared entry (disabled or ready)
appends one line to .metaproject/data/trigger/runs.jsonl — when it fired,
what fired it (on, verbatim), what it did (action, verbatim), the outcome
(ok, no-op, lock-refused, budget-refused, dispatch-refused, or
failed), a human-readable detail, and its cost — { recorded: false, reason }
for every action that calls no model, { recorded: true, usd, tokens } for a
dispatched flow-next. A dispatch also records dispatch: { runId, flow,
task, attempt, branch, closing, refusal?, denials? }. A scheduled agent-task
run records agentTask: { runId, reportPath?, grantedCalls, denials?,
refusal?, permissionMode, network }. Lines written before
tokens/dispatch existed still read. A run that never reaches a concrete entry (no config,
unknown name, malformed entry) is not recorded — that stays a stderr line and
an exit code. status reads this file; it never resolves or re-runs anything.
Taken from a real run:
$ keryx trigger status
keryx trigger status (reading /path/to/project/.metaproject/data/trigger/runs.jsonl):
- nightly-reconcile [enabled] schedule:"0 2 * * *" -> reconcile
last: 2026-09-22T19:33:15.623Z — ok — action "reconcile" completed. [cost: not recorded (this action does not call a model — reconcile/rebuild are deterministic, no spend to record)]
Locking. One project maintenance lock —
.metaproject/data/.locks/maintenance.lock; the .locks directory carries its
own .gitignore (*), so a git add -A that runs while a build holds the
lock (the post-commit hook's own rebuild is such a moment) never commits it,
and it stays writable inside the unattended sandbox — is shared by keryx sync --apply,
keryx gdgraph build, and the triggered reconcile, rebuild and
open-flow. A triggered run that finds it held refuses at once
(lock-refused, exit 0, the holder's pid named) — one run stays exactly one
pass. An interactive sync --apply or gdgraph build waits instead, up to
KERYX_MAINTENANCE_LOCK_WAIT_MS (default 120 s), then exits 75 naming the
holder's pid — "not run", not "build failed"; the post-commit hook reports
exit 75 as a skipped rebuild. The lock is re-entrant within one process's
call chain, so sync --apply building the graph, or a triggered reconcile
running sync --apply, never waits on itself. A dispatching flow-next does
not hold it while the agent runs — the agent's own keryx gdgraph build
must be able to take it — and uses its own per-flow dispatch lock instead.
(Flow 286 described its trigger lock as the one the interactive commands take;
until this change they took none.)
Installing hooks. install's blocks live beside the ones keryx sync
install-hooks (its keryx-sync block) and keryx update (its
gdgraph-post-commit, gdwiki-post-commit, etc. blocks) already write into
the same post-merge/post-commit/post-checkout files — each installer
owns only its own delimited block, so all of them, plus any hand-authored
content already in the file, coexist regardless of install order.
Scheduling. schedule <name> runs no daemon and installs nothing itself —
it prints a ready-to-use cron line and an alternative systemd service+timer
pair for you to install with your own scheduler. The timer carries a real
OnCalendar=: each cron field is expanded to an explicit list, which systemd
always accepts, and a test checks it with systemd-analyze calendar. A cron
with no systemd equivalent (day-of-month AND day-of-week both restricted) keeps a
commented placeholder and says why. Unit names are keryx-<projecthash>-<name>,
so two projects never share one. To have keryx install the timer for you, after a
confirmation, use schedule. Both bake in the absolute
path to the interpreter (node/bun) and script actually running keryx
trigger schedule, plus an explicit PATH (that interpreter's own directory,
then /usr/local/bin:/usr/bin:/bin, for whatever the action shells out to,
e.g. git) — never bare keryx, because cron and a systemd unit resolve
commands with a minimal or absent PATH that a version-manager install (nvm,
bun) is frequently not on. Output is appended to
.metaproject/data/trigger/<name>.schedule.log. Assumption, stated rather
than hidden: the printed paths stay valid only while the resolved
interpreter and script stay where they were at the moment you ran schedule
— an nvm prune, or a node/bun version switch, afterward needs a
regenerate-and-reinstall of the line.
Honest limits¶
- A dispatched task is "done" by the dispatcher's checks, not by review.
task donemeans: the turn ended normally, the trigger branch got a commit, andkeryx health gatepassed in the worktree. Nobody has read the diff; the branch waits for you. A fresh worktree has no dependencies of its own — the project'snode_modules, when present, is linked in for the gate. - The unattended text floor is defence in depth. It is over-broad by
design and still incomplete by construction (a shell can spell a command
many ways). The boundary is the hardened sandbox, which
trustrequires — Linux with a working bubblewrap only in this version. - Review the trigger branch before you install or build it. The agent can
commit anything a task could — including a
package.jsonscript or a build step that runs when you later install or build that branch on your machine. - The sandbox hides your home, not the host.
/stays readable (read-only) outside the hidden directories, so a secret kept outside$HOMEand outside the known deny list —/etc/some-token, another user's readable file — is visible to the agent's commands, and through them to the model provider. - Cost is priced from your rates. keryx has no price table; if
ratesare wrong, both ceilings are wrong by the same factor. Token counts are summed from every usage event the provider emits.
intake¶
Take work in from GitHub as cards in Telegram. keryx serve polls the repositories you
list for tickets assigned to you, review requests, failed CI on your own pull requests,
comments on your pull requests, and movement on the flow board, and sends each new event as
a card with buttons. This command is your view of that: what is waiting, the cards, and the
pause switch. Nothing it does writes to GitHub. The full picture is in the
work intake guide.
keryx intake status [--json]
keryx intake list [--json]
keryx intake pause
keryx intake resume
keryx intake poll [--json]
keryx intake report [--json]
| Subcommand | What it does |
|---|---|
status |
Whether intake is on or paused, how many cards wait, are queued, deferred or decided, the repositories, the GitHub account the project path selects, the time of the last poll and its outcome. |
list |
The cards, newest first: id, state, kind, repository, title and the choice made. |
pause |
Stops the automatic poll. Cards already queued still go out, and buttons already in Telegram still work. |
resume |
Starts the automatic poll again. |
poll |
One poll now, even while paused. New cards are queued and go out with the next serve tick. Exits with code 1 when the poll failed. |
report |
What the cards were worth: cards per kind, decisions, median time to answer, how often you chose what the assessor suggested, and the card, flow and pull request chain. |
--json prints the same data as JSON for status, list, poll and report.
Intake is off for a project until .metaproject/data/intake/config.json exists, says
"enabled": true and lists repos; keryx serve skips every other project and poll refuses with
a one-line how to enable it. status shows the same line. The settings, the Telegram buttons, the
/intake modal in keryx shell and the limits are described in the guide.
schedule¶
Scheduled agent tasks, run in the background. You describe a task and a cadence,
for example "check open PRs on my repo every 4 hours and summarise what needs my
attention". keryx shows you a confirmation card. Only after you confirm does it
store the schedule and install an OS timer that runs the task unattended. Each run
leaves a report you read later in keryx shell or with keryx schedule show.
keryx still runs no daemon of its own. The OS scheduler calls keryx trigger run
<name> from the project root (a digest is the exception: keryx serve runs it, see below):
| Backend | Where | Catch-up after the machine was off/asleep |
|---|---|---|
systemd --user (Linux) |
~/.config/systemd/user/keryx-<projecthash>-<name>.{service,timer}, OnCalendar= translated from the cron, Persistent=true |
one catch-up run at the next boot/wake |
| launchd (macOS) | ~/Library/LaunchAgents/ai.keryx.<projecthash>.<name>.plist, StartCalendarInterval |
one catch-up run on wake |
| cron (elsewhere) | a marked block in your crontab (# >>> keryx-managed <projecthash> <name> >>>) |
none |
keryx schedule add --name <name> --every "<cadence>" --prompt "<task>" \
--provider <p> --model <m> --rates <in>,<out> --ceiling <usd> \
[--max-seconds 600] [--mode ask|trust] [--network off|full|allowlist] \
[--domain example.com]... [--tool <id>]... [--repo owner/name]... [--backend systemd|launchd|cron] [--yes]
keryx schedule add --digest --name <name> --every "<cadence>" \
--provider <p> --model <m> --rates <in>,<out> --ceiling <usd> \
[--repo owner/name]... [--topic <name>] [--memory-mb 512] [--max-seconds 600] [--yes]
keryx schedule add --review-pr <pull request URL> --name <name> --every "<cadence>" \
--provider <p> --model <m> --rates <in>,<out> --ceiling <usd> \
[--budget-minutes N] [--budget-tokens N] [--stall-minutes 10] [--max-seconds N] [--yes]
keryx schedule list
keryx schedule show <name>
keryx schedule pause <name>
keryx schedule resume <name>
keryx schedule run <name>
keryx schedule remove <name> [--yes]
| Subcommand | Description |
|---|---|
add |
Draft the schedule, print the confirmation card, then store it and install its timer only after you confirm: y at the prompt, or --yes. It needs a terminal on both stdin and stdout; without one it refuses (exit 1), --yes included, so a pipe, an agent's shell or an unattended run cannot confirm it. Declining writes nothing and installs nothing. |
list |
Every schedule: cadence, enabled or paused, timer installed or not, next run, last outcome with its cost, and the last report's path. |
show <name> |
The same row plus the prompt, the runner and budget, the grants, the last five runs, and the latest report. |
pause <name> |
Disable the timer and mark the entry disabled. A fire while paused records no-op. |
resume <name> |
Re-enable both. No new confirmation is asked, so resume first checks that the entry still carries this machine's signature and that every granted binary still matches its pin, and refuses with the reason otherwise. It reinstalls the keryx recorded when you confirmed the card, never the one running resume. |
run <name> |
One pass now (keryx trigger run --schedule <name>: local schedules only). |
remove <name> |
After a confirmation, uninstall the timer and delete the entry. keryx deletes only files that carry its # keryx-managed <projecthash> <name> header. Anything else found at those paths is left untouched and named. |
Digest (add --digest). A digest is a schedule that reads GitHub and the flow board and
sends you what changed, what is stuck, what needs your decision and every "PR merged, flow
closed, effect not checked" chain, in Telegram. See the guide
Get a GitHub and board digest on a schedule. In short:
- Who runs it.
keryx serve. A digest has no OS timer (install: none), sopause,resumeandremovetouch no timer, and serve must be running. A missed time runs once when serve comes back, and a time is claimed before the run starts, so it is never run twice. - Configuration.
--everyis the schedule;--repo(repeatable, defaultMrCipherSmith/keryx) is the only set of repositories it may read;--topic(defaultDigest) is the service topic used when the project has no live remote session, otherwise the digest goes into that session's topic.--promptis not needed. A digest defaults its tools togh.pr.list,gh.issue.list,gh.pr.review-requestedandgh.run.failed. - Read-only. Every
ghtool is on a read-only allow-list (pr list|view|checks,issue list|view,run list) with a fixed argv and no writing word, and the digest never callsgh auth switchorgh auth login.ghruns as the account the project path selects (~/work/**the work account, everything else the personal one). - Limits.
--ceiling(dollars, for the optional model summary),--max-seconds(timeout) and--memory-mb(memory growth during the run, default 512). A run over a limit is stopped and reported. - Baseline. The first run only records a snapshot and is marked
baseline; a later run with no changes has no items. - Seeing it.
listandshowprint the digest, its next run, the last run's status and the last delivery;/schedulesinkeryx shellshows the same, withpto pause or resume.
Autonomous review (add --review-pr). A schedule that reviews one pull request with nobody at
the keyboard. Each fire runs keryx review run --autonomous through the OS timer, so
it needs no --prompt and is covered by the same confirmation card; the pull request, the profile
and the budgets are part of what you confirm. --budget-minutes and --budget-tokens stop a run at
a safe point and --stall-minutes (default 10) stops one that makes no progress; they belong to a
review and are refused without --review-pr. --digest and --review-pr cannot be combined. A fire
on a head that was already reviewed to the end reuses the finished review; a fire that finds the
previous run alive refuses. The run's report.md is copied to
.metaproject/data/trigger/reports/<name>/<runId>.md. See
Review a pull request with nobody at the keyboard.
Drafting checks the provider before the card is even shown. add, /schedule
and the agent's schedule_create tool all draft through the same code, which
refuses — before anything is written or installed — a provider that is not known
to report token usage on every response (anthropic, openai, gemini, or a
registry provider whose entry sets streamUsage, for example grok or
deepseek; see Dispatching flow-next), and a provider
with no usable credential in this environment. Both use the same resolution the
scheduled run itself would use, so a confirmed card cannot install a timer whose
every fire is bound to refuse with dispatch-refused (provider-usage-unknown) or
for want of a credential.
Cadence. A five-field cron expression, or one of every N hours (a divisor of
24), every N minutes (5, 10, 15, 20 or 30), hourly, daily at HH:MM,
weekdays at HH:MM, every monday at HH:MM. The phrase is turned into cron by
fixed code, never by the model. The card shows the cron and the next three run
times, so a wrong translation is visible before anything is installed. A cron that
restricts both day-of-month and day-of-week is refused: cron fires on either,
systemd and launchd only on both.
The confirmation card lists:
- the cadence and the next three run times;
- the prompt;
- the provider and model, with the permission mode;
- the schedule's ceiling, its max seconds, and your rates;
- the network mode;
- every granted tool, with the absolute binary it runs, the repositories it may touch, and the account it acts as;
- the backend and file paths, and the exact command the scheduler will execute;
- the linger status;
- the limits.
The same card appears in keryx shell for /schedule and for the agent's
schedule_create tool.
What the scheduled run is, and what it may do. An agent-task runs one
unattended agent turn on your prompt. The unattended posture and the text floor are
exactly those of a dispatched flow-next (see trigger). The differences:
- there is no flow, task, worktree branch, commit or health gate;
- the agent's working directory is a scratch directory, and the project is bound read-only;
- the roster is
get_cwd,list_dir,read_file,shell_exec(inside the hardened sandbox) and the granted tools. There is noapply_patch, web, MCP, subagents orask_user.
--mode ask (the default) makes every shell command an approval request, and an
unattended run denies every approval request. Granted tools still run under ask,
because they are read-only. --mode trust lets non-destructive shell commands run
inside the sandbox, and refuses to start without one. auto is never accepted.
The floor additionally refuses keryx schedule add|remove|pause|resume|run,
keryx trigger schedule, systemctl … enable|disable|start|stop|daemon-reload,
crontab, launchctl and loginctl. It also refuses any write to the schedule
store or the reports. No grant lifts any of it.
Grants.
- Network:
off(default:--unshare-net),full(the host's whole network, always shown with the NETWORK ON warning), orallowlist(Linux only).allowliststill runs--unshare-net— the sandbox's netns has only its own private loopback, and the host's real loopback stays unreachable from inside it — plus a UNIX socket bind-mounted in for a domain-allowlisting proxy keryx runs OUTSIDE the sandbox. A keryx-shipped forwarder inside the sandbox bridgesHTTP(S)_PROXYto that socket (most tools cannot speak to a unix-socket proxy directly).--domain(repeatable, or comma-separated) names every reachable domain — an exact name or a*.domainwildcard; at least one is required. The proxy allows plain HTTP byHostand HTTPSCONNECTby authority ONLY for listed domains, refuses a bare IP-literal target outright, and after resolving an allowed name refuses a loopback, private, link-local, CGNAT, unique-local or cloud-metadata address — connecting to the exact address it checked, never a fresh lookup, so a name that resolves differently on a second (DNS-rebinding) lookup cannot slip through. HTTPS is a blindCONNECTrelay by default (no TLS termination), so the proxy cannot see SNI or an in-tunnelHost.allowlistgoverns ONLY the agent's ownshell_execcommands inside the sandbox — the model call and every granted tool already run OUTSIDE the sandbox, on your own network, and are unaffected by this grant. A client that ignoresHTTP(S)_PROXYgets no network at all (the sandbox's netns has no other route out). macOS, and any host without a working bwrap, refusesallowlistwith the reason — it never silently falls back toofforfull. Every allow/deny decision (host, port, allowed, reason, time) is recorded in the run's report and its ledger entry, and is part of the operator-confirmed, signed schedule content — changing the domain list after confirmation refuses the run withgrants-changed, the same as changing anything else about it. - Granted tools: a fixed, reviewed catalogue:
gh.pr.list,gh.pr.view,gh.pr.checks,gh.issue.list,gh.issue.view,gh.run.list,gh.pr.review-requested,gh.run.failed(all read-only: see the digest above). It includes nogh apiand no free-form argv. keryx runs a granted tool itself, outside the sandbox, with your credentials, viaexecFile: - there is no shell;
- the argv is fixed;
- parameters are pattern-checked, and none may start with
-; - the repository must be one listed with
--repo; - each call has a 30-second timeout and a 64 KB output cap.
The model only ever receives the tool's output, after the secret detector has run
over it and the exact value of every credential-looking environment variable has
been scrubbed. Your token is never in the sandbox, the model context, the provider
request or the report. A granted tool's output (PR bodies, comments) is marked
untrusted: under trust, a later write that follows it asks, and so is denied.
Security.
- Signed by this machine. Every stored schedule is signed with an HMAC-SHA256.
The key is a per-machine secret in keryx's user-global directory
(
schedule-hmac.key, 0600), created the first time you confirm a schedule. The key lives outside the project and is hidden from every sandboxed run. A committed or hand-written store entry cannot carry a valid signature, so it never runs. A missing or group-readable key refuses every stored schedule (schedule-key-unavailable). - Never committed. A schedule store tracked by git is refused whole.
- Timers only, never git hooks. A stored schedule can only be
on.kind: "schedule". It is never a git-event trigger and never gets a git hook. - Your own name wins. A committed
triggers.jsonentry with the same name as a local schedule is refused, and the local schedule wins. The installed timer runskeryx trigger run --schedule <name>, which resolves only local schedules. - Granted binaries are pinned. Each granted program is recorded with its
realpath and sha256, and both are signed. Its basename must equal the program
(
bins.ghis a program namedgh), and it must resolve outside the project. A swapped binary or a repointed symlink refuses the run withgrants-changed. - Scrubbed output. Output is scrubbed first and capped after, and the trailing partial line is dropped. The scrubber removes:
- the exact value of every
*TOKEN*/*SECRET*/*KEY*/*AUTH*variable; - the exact value of
gh auth token, which keryx reads once per run, outside the sandbox, and never logs or shows; - classic
gh?_and fine-grainedgithub_pat_tokens; Authorization: token|bearerheader values.- No shortcut around the card. In any agent's shell, including
automode, these always ask, are never remembered, and are refused as allowlist patterns: keryx schedule add|remove|pause|resume|run;keryx trigger schedule|install;crontab,launchctlandloginctl;systemctlwithenable|link|start|restart|daemon-reload|edit|disable|stop|…;- any write into
~/.config/systemd/useror~/Library/LaunchAgents; - starting an interactive keryx (
keryx,keryx shellwithout-p), a terminal driver around one (script,screen,tmux,unbuffer), or typing into a running session (tmux send-keys,screen -X stuff).
keryx schedule add|remove|pause|resume|run also refuse outright inside an agent's
shell (KERYX_TOOL_CALL=1). So do a nested keryx's /schedule, schedule_create,
and the /schedules pause, resume, run-now and delete keys, because an agent can
type into a keryx it started through a pseudo-terminal.
- Clean cards. Model-supplied text on the card has ANSI and control characters
removed, and newlines are shown as ⏎.
- Operator-only storage. schedule_create stores only the draft whose card the
operator confirmed. That draft is bound to a one-time token and dropped when the
operator declines.
- Isolated runs. One run cannot read another run's scratch directory. The scratch
parent is $XDG_RUNTIME_DIR/keryx-agent-tasks, or <tmpdir>/keryx-agent-tasks-<uid>
when that variable is unset. It must be a real directory you own with mode 0700, or
the run is refused.
- A terminal is required. keryx schedule add, resume and run need an
interactive terminal on stdin and stdout, --yes included. remove and pause do
not, because they only reduce what runs. The shell's /schedule card and the
schedule_create tool are the in-shell paths.
- Granted programs run from an empty directory. Granted programs, gh auth token
and the account lookup on the card all run from an empty keryx-owned directory, never
from the project, so a committed .tool-versions/.mise.toml/.envrc cannot steer
them. Drafting refuses a version-manager shim (…/shims/gh) and suggests the real
binary (mise which gh, asdf which gh).
- Script wrappers are pinned. A #! script wrapper outside the project (for example
a ~/.local/bin/gh that picks an account) is allowed. Its sha256, inode and mtime are
pinned and signed, and so is its interpreter: for #!/usr/bin/env X, X is resolved on
PATH. The run re-checks both, and resolves the interpreter again on the run's PATH.
The card shows gh: script wrapper … (interpreter …) — pinned.
- The config directory is hidden and pinned. keryx's config directory (auth.json,
provider keys, the signing key) is always hidden inside the unattended sandbox, even
when XDG_DATA_HOME is outside $HOME. A config directory inside the project is
refused. The installed timer pins XDG_DATA_HOME when you have it set, so the
scheduler finds the same key.
- Binaries are re-checked before each exec. A granted binary's inode, size and mtime
are re-checked before every exec, not only at the start of the run.
- The timer runs the keryx you confirmed. The card's runs: line is the exact
command, quoted as the unit carries it. That command and the pinned environment are
signed with the entry, so resume and reinstall never swap in another keryx. A keryx
running from inside the project (for example bun src/cli.ts) is refused when the
schedule is drafted: install keryx globally for schedules.
- Reports are read only from their own place. The Report tab and the notices read
only .metaproject/data/trigger/reports/<name>/<runId>.md: a regular file (never a
link or a FIFO), under a size cap, with control characters removed. A reportPath
in runs.jsonl that points anywhere else is ignored.
- Honest limit. The shell floor is text analysis, and a same-user shell in trust
mode can get past it with a variable or a script. The terminal requirement, the
nested-keryx refusal and the signing key are the gates behind it.
Where things live.
- The schedules are stored in
.metaproject/data/trigger/schedules.json. The file is per machine and never committed:.metaproject/data/trigger/.gitignorelists it and the reports. Only keryx writes it, and only after a confirmation. - A schedule is never read from the committed
triggers.json. Anagent-taskthere is refused on load. - Each entry carries the hash of the content you confirmed: name, cadence, prompt,
runner, budget and grants.
keryx trigger runrefuses an entry whose content no longer matches that hash (dispatch-refused, refusalgrants-changed) and calls no model, so an edit behind an installed timer never runs. - Reports are written by the dispatcher, never by the agent, to
.metaproject/data/trigger/reports/<name>/<runId>.md. Each report has a header (outcome, cost, granted calls, denials) and keeps the last 20 per schedule. - Every run, including a refusal, is a line in
.metaproject/data/trigger/runs.jsonl. The line carriesagentTask: { runId, reportPath, grantedCalls, denials, refusal? }and the cost, sokeryx trigger statusandkeryx governance reportsee it.
Spend. Before the first model call, a run reserves spend under the project-wide
spend lock. The reservation is the smaller of the project ceiling and the schedule's
own --ceiling. At or over either ceiling it records budget-refused and calls no
model. The run's closing record carries the runId, tokens and USD, and closes the
reservation.
In keryx shell:
/schedulecreates a schedule from the shell, with the same card.- The sidebar's Schedules section (after Triggers) shows one compact row per
schedule: name, next run time (or
paused, ornot installedwhen the timer files are gone), and last outcome with its cost (ok,failed,refused; a hand-resolved run showsresolvedin the attention colour). A click on a row opens that schedule's detail. /schedulesopens the list:↑/↓to select,Enterto open,Escto close./schedules <name>opens one schedule directly.- The detail modal has four tabs: Overview (cadence, next runs in local time, last run, installed timer, its unit, the linger state, whether the entry still verifies, runner, budget, prompt), Grants (network, account, repos, granted tools, and each program's pinned realpath and short sha256, with a wrapper's interpreter), Runs (the last 12 outcomes with cost and refusal; reservations do not count), and Report (the latest report, scrollable). The first line of every tab lists its keys:
ppauses or resumes, in one step;rthenyruns it now, askeryx trigger run --schedule <name>in a detached child process with its own log;dthenydeletes it (uninstalls the timer and removes the entry);- any other key cancels an armed action.
These actions call the same functions as keryx schedule pause|resume|remove.
- When a scheduled run finishes in the background (another process), the section and
an open modal update on the next check, without a restart. Each new report is
announced once in the transcript.
- The agent's schedule_create tool creates a schedule from plain language. It
always asks, in every permission mode including auto, is never remembered,
and never offers "always". The model can propose a schedule; only you confirm it.
Honest limits¶
- The machine must be on. A suspended laptop misses runs. systemd
(
Persistent=true) and launchd run one catch-up run on the next boot or wake, not one per missed slot. cron catches up nothing. - Linger. Without linger (
loginctl show-user $USER -p Linger), a systemd--usertimer does not run while you are logged out. keryx reads and shows the linger state and never runsloginctl enable-lingerfor you. - The hardened sandbox is Linux-only. On macOS,
trustrefuses, so a schedule there isaskwith granted tools only. - Pinned paths. The installed timer bakes in the absolute interpreter and script that created it, and the absolute path of each granted program. After a version-manager switch, remove the schedule and add it again.
- Report text from third parties. Granted tools return text anyone can
write (PR bodies). Under
askit cannot cause a write, but it can mislead the summary.
governance¶
One report over what is already recorded — spend, confirmations, signatures
and gate outcomes, unified across flows and (optionally) across projects.
Read-only: it never re-runs a gate, never calls a model or a network service,
and the only files it writes are its own report artifacts. A figure nobody
recorded is reported as "not recorded", never as zero — the same rule
keryx review budget's spend_status: not-recorded and keryx trigger
status's cost: not recorded (<reason>) already follow.
keryx governance report [--flow <id>] [--owner <name>] [--since <iso>] [--until <iso>] [--all-projects] [--json]
keryx governance show [--json]
| Subcommand | Description |
|---|---|
report |
Build the report from what is on disk right now, write .metaproject/data/governance/artifacts/latest.md and latest.json, and print it. |
show |
Reprint the most recently written report without regenerating it. Prints "No governance report yet" if report has never run. |
--flow <id> |
Narrow to one flow, by its bare id (e.g. 291). |
--owner <name> |
Narrow to flows whose owner's identity value matches exactly. A flow with no owner set is excluded. |
--since <iso> / --until <iso> |
Narrow to flows whose own updatedAt falls in the range, and trigger runs whose own at falls in the range. |
--all-projects |
Also cover every project in the user-global registry (keryx projects), not only the current one. A registered project whose path is missing or unreadable is listed with a state: "skipped" reason instead of failing the whole report. |
--json |
Print the report as JSON instead of markdown. |
What it reads, and what it never does¶
Per flow (.metaproject/flows/<id>/flow.json and its reviews/*/manifest.json):
- Review-round spend (input tokens, output tokens, USD), summed across
every round's
manifest.jsoncostfield. A figure is a number only when at least one round reported it; a flow with partial coverage (some rounds recorded cost, some did not) reports the sum together with arounds_with_cost/rounds_totalcount, never silently dropping the gap. - Confirmations and signatures — who confirmed each acceptance criterion
and who signed completion, joined from
acConfirmedandsignatures(flow 289), with each identity's basis (stated,derived,unknown) shown beside the name. Aderived/unknownidentity is never presented as a verified confirmation. A flow with nosignaturesfield at all — every flow completed before flow 289 — reportsconfirmations: not recorded (predates signing). - Gate outcomes — every
flow completeattempt's gate results (pass, fail, skipped), fromFlowState.completionAttempts(flow 291). Absent on every completion attempt made before flow 291, which reportsgate outcomes: not recordedrather than inferring anything. - Summary and effect (flow 364), from
description.mdand the task list, with no model.summary:is the first sentence of## Expected Outcome(else## Problem), tasks done of all, and the tasks still open.effect:is the first bullet of## Outcome criteria(with(+N more)when there are more) — the same parserkeryx product indexuses.effect: not statednames why: no such section, only the template hint, ordescription.mdunreadable. Inlatest.jsonthey aresummaryandeffect; a report stored before them readsnot recordedfor both.
Per project (.metaproject/data/trigger/runs.jsonl):
- Trigger spend — USD summed over every fired-trigger run whose cost was
recorded, plus the count of CLOSED runs whose cost was not recorded (never
folded into the sum as
$0), plus — since flow 300 — the open spend reservations (openReservations,openReservedUsdin the JSON; "N open reservation(s) totaling $X (reserved, not spent …)" in the markdown). An open reservation is a run whose dispatch reserved spend and has no closing record and nokeryx trigger resolveyet; it counts inrunsTotal("M run(s) total (K open)") but never in the not-recorded count and never inspentUsd— the same name and the same rule the per-flow "Open reservations" below use. An operator'sreservation-resolvedis the killed run's closing record, and belongs to the flow the reservation was opened for. This is the project's TRUE total, project-wide across every trigger and every action kind, whether or not the run named a flow.attributedToFlowsUsdsays how much of that total is ALSO shown under a flow's own "Dispatch runs" section below (spentUsdthere); it is a SUBSET of the project total, never an amount on top of it — do not add the project'sspentUsdto any flow'sdispatch.spend.spentUsd, that double-counts every dispatch dollar. The markdown says so in place, with "of which $X is shown under flows below (not additive)". An absent ledger reports a demonstrated$0(nothing has ever fired); an unreadable one reportsnot recordedwith the reason. - Policy decisions — an INTERACTIVE session's own allow/ask/deny decisions
have no durable record in this build, so this section always reads
not recordedwith that reason. An UNATTENDED dispatch run is different: flow 290 records every such run's denials, and this report reads them — see "Dispatch runs" below, not this line.
Per flow, also from runs.jsonl (flow 297):
- Dispatch runs — every unattended
flow-nextdispatch run that named this flow (TriggerDispatchRecord.flow), joined byrunId: the trigger, the time, the outcome (ok,failed,dispatch-refused,budget-refused, or an operator'sreservation-resolved), the cost, and every call its unattended approval gate denied — the tool and the reason, timed by the run's ownat. Spend is summed only over these CLOSED runs, the same "never folded into$0" rule trigger spend keeps. ThisspentUsdis included in the project's trigger spend above, not additional to it —includedInProjectTriggerSpend: truein the JSON, and the markdown line says "(included in the project's trigger spend above — not additive)". - Open reservations — a spend reservation this flow's dispatch opened
(
keryx trigger run, before its first model call) that no closing record — and nokeryx trigger resolve— has closed yet: a killed run. Shown as "reserved, not spent", on its own line, never added into the spend figure above. A reservation recorded before this change carries no flow reference and stays out of every flow's section — it is still counted at the project-wide trigger-spend line, as an open reservation. - A report-only
flow-nextentry (nodispatchblock — it only reports the next task, no model call) writes nodispatchrecord at all, so it never appears in either list here; it is still counted at the project-wide trigger-spend line, exactly as before this change.
The report never re-runs flow complete, review ingest/budget, health
run, or any security scan, and never calls a model or a network service. The
only files a run writes are its own two artifacts, below.
Artifacts¶
report writes both files on every run, following the same convention
keryx health run uses:
.metaproject/data/governance/artifacts/latest.md— the human-readable report, the same text printed to the terminal..metaproject/data/governance/artifacts/latest.json— schema-versioned (schemaVersion: 1), machine-readable.show's reader is shape-guarded: a missing or malformed stored file is treated as "no report yet", never a crash and never silently read as an empty-but-valid report.
product¶
The product's intent as a derived index, and the list of intents that were
closed in code and never looked at again. Two commands and nothing else.
Read-only over the project: it reads .metaproject/flows/*/ and
docs/requirements/*/, writes one file of its own, calls no model and no
network service, and gates nothing. No other keryx command calls it and no flow
transition waits on it.
| Subcommand | Description |
|---|---|
index |
Read every flow directory and every requirements package into one intent index at .metaproject/data/product/index.json, and print a summary with the count of entries that state no intent. Deterministic: the same corpus gives byte-identical output, and the body carries no timestamp. |
open |
List the intents that are closed in code and have no recorded observation. Each row names the flow and its outcome criterion, or the literal text not measured — no instrument stated. |
--json |
Print the index summary, or the open report, as JSON. |
An intent is the Problem or Expected Outcome sentence of a flow (or the
outcome criteria of a requirements package), with its acceptance criteria read
through the same [verify: ...] kinds keryx flow uses, its status and its
close date. A flow whose description states no intent, only scaffold
placeholders, is counted as having none; it is not an error.
The open header separates three cases, so a number is never mistaken for an
answer:
intents closed in code, never checked for effect: N of M
no outcome criterion stated: a
criterion stated, never observed: b
observed: c (helped h, no effect n, harmed m, inconclusive i)
A flow's outcome criterion is written in a ## Outcome criteria section of its
description.md; keryx flow init leaves that slot in the template, and an
untouched hint is not a declared criterion. not measured — <reason> states
that there is no instrument. An observation is a line at column 0 of the flow's
journal.md:
<verdict> is exactly one of helped, no-effect, harmed, inconclusive.
A requirements package records the same in an ## Outcome observations section
of its README.md, as - <verdict> — <note>; it is stored and read only, and the
package stays in the open list. index reads a flow's line, and that flow
leaves the open list. A malformed line is a failure that names the flow or
package: index lists it and exits non-zero, and the flow stays in the open
list, which shows the failure count. Nothing writes the line for you and
nothing gates on it. The index stores a content fingerprint of the flows and requirements
it was read from. When the index is missing or unreadable, or the fingerprint no
longer matches the tree, open exits non-zero and names keryx product index
instead of answering from stale data. Only content counts, not file times: a
restored or replaced directory is caught, and a bare touch is not.
The index is disposable. .metaproject/data/product/ can be deleted at any time
and keryx product index rebuilds an equivalent one. In the TUI, /product shows the same
header and rows.
Each entry also prints outcome author: — agent, human, or unknown for a
flow that never recorded it (see the outcome author). The
index stores it as an optional field, so an index written before it existed stays
valid; it labels the sample for G1a and gates nothing.
hooks¶
keryx hooks list [--json]
keryx hooks validate [--json] [--ci]
keryx hooks test <id> [--event <name>] [--payload-file <path>] [--json] [--profile <id>]
keryx hooks enable <id> [--user]
keryx hooks trust [--yes]
keryx hooks untrust
keryx hooks disable <id> [--user] [--acknowledge-gate-risk]
Project command hooks run only after keryx hooks trust; changing the file
revokes trust. A project file cannot disable a built-in gate; --user
--acknowledge-gate-risk can.
The CLI over keryx shell's lifecycle hook runtime (W6): ten named events
(SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
PostToolUseFailure, PreCompact, Stop, SubagentStart, SubagentStop,
SessionEnd), a config-file pair merged over Keryx's five built-in
registrations: built-in < user (~/.keryx/hooks.json) < project
(.metaproject/hooks.json, version-controlled, highest precedence).
| Subcommand | Description |
|---|---|
list |
Print the merged, resolved registration set — id, event(s), matcher, class, scope, enabled, appliesToChildAgents, timeoutMs, and the command/argv or builtin name. A built-in registered on several events (e.g. keryx.learning-observer) is one row with an events array. With no config files present, exactly the five built-ins (keryx.ctx-guard, keryx.security-check-input, keryx.security-check-output, keryx.learning-observer, keryx.impact-evidence), all enabled: true. A config error prints diagnostics and exits non-zero. |
validate |
Validate both .metaproject/hooks.json and ~/.keryx/hooks.json against hook-config.schema.json: schema conformance, a project/user id colliding with a built-in (hook-id-collides-with-builtin), a duplicate id, and — best-effort, no execution — that each hook command's argv[0] resolves (an absolute path exists, or is found on PATH; keryx always resolves). An unresolved argv[0] is a warning, never a reason to fail. Exit is non-zero on any diagnostic; --ci is accepted for a machine-friendly pipeline invocation and changes no exit-code semantics beyond what --json already gives. |
test <id> |
Run one registered hook once, through the real runner (createRealHookRunner), against a synthetic payload for its event — or the JSON in --payload-file — and report its decision (parsed through the same codec the runtime uses), exit code, stdout/stderr (truncated), duration, failure class, and what the failure-semantics effect would be under --profile (default monitored-trusted-local). The two in-process built-ins (keryx.learning-observer, keryx.impact-evidence) report their port's result instead of a process exit code. --event <name> picks which event to run under when the id is registered on more than one (e.g. the observer). Exit is 0 whenever the hook ran — even when it denied, timed out or crashed — and non-zero only when the id is unknown or the config fails to load. |
enable <id> / disable <id> |
Flip a registration's enabled state in the project file by default, or ~/.keryx/hooks.json with --user, creating it with {schemaVersion:"1.0.0", hooks:{}} if absent. For a built-in id: disable writes a disable-only override ({id, enabled:false}) into every event list the built-in is registered on, and enable removes exactly that Keryx-managed override — refusing (non-zero, no write) if the override present is not one _keryxManaged.managedHookIds records, since that means a person wrote it by hand. For a project/user hook defined in that file: disable/enable flips its own enabled field, without deleting or reordering any other entry. Every write is atomic (temp file + rename) and the resulting document is re-validated against the schema before it lands — an invalid result is refused with nothing written. Unknown id: non-zero, nothing written. |
_keryxManaged¶
hooks enable/disable record what THEY added or changed in
_keryxManaged: {tool: "keryx", version: <cli version>, managedHookIds: [...]},
so a later call — or a person reading the file — can tell a Keryx-written
entry from a hand-authored one and never silently overwrites the latter. This
is the same discipline as the host-config installers keryx orient
install-hook/keryx security hooks install already use for the Claude settings
file (.claude/settings.local.json by default) and friends, applied to Keryx's own two files.
bundle¶
keryx bundle export --scope <project|team|user> [--include <glob>]... [--kind <k,...>] [--id <id>] [--target-harness <h,...>] <out> [--json]
keryx bundle import <bundle> [--target-scope <scope>] [--render-for <h,...>] [--force <path>]... [--allow-hooks] [--dry-run] [--json]
keryx bundle import <catalog-dir> --external [--dry-run] [--json]
keryx bundle inspect <bundle> [--target-scope <scope>] [--json]
keryx bundle verify <bundle> [--json]
keryx bundle verify --external-imports [--json]
keryx bundle uninstall <bundleId> --target-scope <scope> [--dry-run] [--json]
Flow 313 (W4 portability): move skills, rules, agents, memory entries,
learned patterns and hook config between scopes and machines as a single
sha256-content-addressed, manifest-described bundle — a directory (with
bundle.json at its root) or a deterministic .tar.gz. This command never
prints file contents, only paths/sha256/counts, and every write goes through
one lifecycle: plan -> W8 audit -> apply, all-or-nothing. A refusal at any
stage means zero bytes were written; applyBundlePlan itself re-checks every
target's checksum immediately before writing (a TOCTOU guard) and rolls back
every write it already made if any single one fails.
Scope roots. project/team share .metaproject/ at the project root
(team is a labeling discipline over the same tree, not a separate
directory); user resolves to ~/.keryx/ (KERYX_HOME, or an explicit
--home-dir-style override in tests, wins over the real home directory —
the same resolution keryx hooks uses for ~/.keryx/hooks.json).
| Subcommand | Description |
|---|---|
export |
Collect every matching skill/rule/agent/memory-entry/learned-pattern/hook-config under --scope's root, filtered by --include glob(s) and/or --kind, and write a bundle to <out> (a .tar.gz/.tgz path, or an empty/absent directory). --id overrides the default deterministic bundleId: for project/team with a git remote, a hash of that remote's normalized identity (keryx-<scope>-<hash>); for user scope, or project/team with no remote, a hash of the export's own sorted path\tsha256 content list instead — so two unrelated exports never collide on id, and the same content always reproduces the same id. Because that default id changes whenever content changes, re-exporting the SAME logical bundle across its lifetime should pass the SAME --id every time (persist it alongside the bundle) — that is what keeps bundle import's ownership tracking (see import below) recognizing a re-export as an update from the same bundle rather than a takeover requiring --force. --target-harness records advisory harness ids in the manifest's compat.targetHarnesses, checked back against the capability matrix by bundle inspect. Refuses (no bytes written) on any invalid hook-config/learned-pattern content, an unparseable agent frontmatter, a source file/directory name that is not portable (path-escape — ASCII letters/digits/./_/- only, no leading .), a match set of zero entries (empty-bundle), or an <out> that already exists non-empty. |
import |
Plan: verify every entry's checksum against the bundle first (any mismatch refuses before anything else runs). An entry whose scope differs from the bundle's own provenance.sourceScope is refused (scope-mismatch) unless --target-scope is passed, so a bundle cannot silently write into a different scope than the one it claims to be from — e.g. a "project" bundle smuggling a scope: user hooks.json into ~/.keryx/. A hook-config entry additionally requires --allow-hooks, or it is refused (hooks-require-opt-in). Each surviving entry is then diffed against the target scope's disk state and applied-state ledger into a bucket — new, identical, update (Keryx wrote the current bytes last time, AND this same bundleId owns that ledger record), or conflict (the current bytes match neither the incoming nor the ledger — a hand edit, an unmanaged pre-existing file, or content unchanged since a DIFFERENT bundleId last wrote it — owned-by-other-bundle, so one bundle can never silently take over a file another bundle still owns). Ownership is not decided by a matching bundleId alone — a hand-crafted bundle can declare any bundleId string it likes. When the ledger record for a target carries a provenance.sourceProject (recorded from the bundle that last wrote it) and this import's own manifest provenance.sourceProject differs from it — including either side simply lacking the field while the other has one — the target is owned-by-other-bundle too, even though the bundleId itself matches. sourceProject is trust-on-first-use, not a forgery barrier: it is a plain sha256 hash of the source project's normalized git remote identity, computed at export time, carried in cleartext in every bundle.json, and reproducible by anyone who knows (or can guess) that remote — a hand-crafted bundle can trivially populate it to match a prior record. What it protects against is accidental cross-project clobbering — two unrelated projects that happen to reuse the same bundleId — not a deliberate attacker who has seen an exported bundle or knows the source repository's remote. When NEITHER the recorded entry NOR the incoming manifest has ever declared a sourceProject (no git remote at either bundle's export time), the two cannot be distinguished this way and the plain bundleId match alone decides update vs new — this is the same trust-on-first-use: the FIRST bundle to write a path under a given bundleId is trusted, and Keryx has no way to later prove a same-bundleId re-import (with or without a matching sourceProject) is the same producer rather than a spoof. A conflict entry blocks the whole import unless its targetRelative or displayId (scope:path) is named in --force; forcing an owned-by-other-bundle conflict transfers ownership of that path to this import's bundleId (recorded in the ledger, and printed with its (owned-by-other-bundle) reason). Every such forced takeover is also reported as its own transferred entry — {displayId, from: <previous bundleId>, fromSourceProject?: <previous sourceProject>} in --json, a transferred <path> from <bundleId> line in human output — not merged into an ordinary written/+ line. --target-scope retargets every entry to one scope, EXCEPT a learned-pattern, whose scope is immutable: a project-scope pattern may only import at project or team scope, a user-scope pattern only at user scope (any other target refuses learned-pattern-scope); every imported pattern is also rewritten to status: "candidate" regardless of what it carried in the bundle (keryx learn accept is the only command that may mark one accepted). --dry-run prints the plan grouped by kind, with each entry's bucket, and stops — no audit, no write, exit 0 only when the plan itself has no unresolved refusal. Otherwise: W8 audit stages every entry that would be written into a temp dir and runs runHarnessAudit's imported-bundles surface; a high/critical unsuppressed finding refuses the whole import. Apply: atomic per-file writes (temp + rename) with a TOCTOU re-check (checksum and symlink chain) and full rollback — files, directories mkdir -p created, and a newly-created private-dir .gitignore — on any single write or ledger-update failure. Updates the target scope's applied-state ledger (.metaproject/data/bundles/applied-state.json or ~/.keryx/bundles/applied-state.json) for every WRITTEN entry unconditionally, and for an identical entry only when the ledger already records that exact path as owned by this same bundleId AND sourceProject — an identical match against a user's own pre-existing file, against a file another bundle's ledger record already owns, or against a same-bundleId record whose sourceProject differs (including one side omitting it), is left completely untouched, so this bundle's own later uninstall cannot delete a file it never wrote, and identical bytes alone can never relabel or erase a recorded sourceProject. After a successful apply that wrote at least one rule entry into project scope, renders the canonical rules library into --render-for's harnesses, or — when --render-for is omitted — every harness that already has the rules-export surface installed (installedRulesExportHarnesses); per-harness installed/unchanged/failed/unsupported is reported, and the command exits 1 (not 0) if any harness's render failed even though the import itself wrote files. |
import --external |
Vets an Agent-Skills-standard catalog directory (<catalog-dir>, not a Keryx bundle) instead: a candidate is the directory itself or an immediate subdirectory holding a SKILL.md. Every regular file under a candidate is read once into an in-memory snapshot; that same snapshot is both hashed (for the registry) and audited — SKILL.md and every other markdown/text file are scanned for secrets, prompt-injection and auto-run directives, not just script files, and a markdown-only skill (with no script files at all) is accepted rather than rejected as inapplicable. Rejects a candidate containing a symlink anywhere, or that is itself a symlink (symlink-refused); with invalid frontmatter — name must match ^[a-z0-9]+(-[a-z0-9]+)*$ (≤64 chars) and description must be non-empty (≤1024 chars) — (invalid-skill-frontmatter); a name shared with another candidate in the same batch, where BOTH are rejected (duplicate-name) rather than the later one silently winning; a scout use/fork decision against the project's own skill catalog (scout-duplicate/scout-overlap — fail closed; author a fork through the normal path instead); a name already recorded with different file hashes (already-imported); or a failing/inapplicable W8 audit (audit-failed/audit-not-applicable). An accepted candidate is recorded by reference only in ~/.keryx/skills/external-imports.json (source directory + per-file sha256, plus an HMAC integrity tag keyed by a per-user secret at ~/.keryx/skills/.external-imports.key) — no skill file is ever copied into .metaproject/skills/, ~/.keryx/skills/<name>/, or any bundle. Reading the registry recomputes and checks that HMAC, and also refuses a case-variant sibling file (e.g. External-Imports.json next to external-imports.json) — both fail closed as corrupt-external-imports-registry, so a bundle cannot plant a forged, "pre-vetted" registry directly. --dry-run vets and prints without recording. Exit 1 only when every candidate was rejected. |
inspect |
Read-only: verifies the bundle and runs the same plan diff import would (against --target-scope, or each entry's own recorded scope), without writing anything — no ledger touch, no temp files, no audit. Also warns, per compat.targetHarnesses, when the capability matrix does not report that harness native/adapter (advisory only). |
verify |
Recomputes every contents[].sha256/sizeBytes in the manifest against the bundle's actual bytes; reports any file present in the bundle but not listed in the manifest (unlisted) too. Exit 0 only when every entry is ok and nothing is unlisted. |
verify --external-imports |
Re-checks every entry in ~/.keryx/skills/external-imports.json (after the same HMAC integrity/case-variant checks import --external applies on read) against its live source: unresolvable (the source directory or a recorded file is gone), checksum-mismatch (a recorded file's bytes changed upstream), or unlisted-file (a file now present under the source that was not part of the recorded set) — each a distinct status, never folded into a generic failure. |
uninstall |
Removes only the files <bundleId>'s applied-state ledger for --target-scope records AND whose current sha256 still equals what Keryx last wrote — a file a person has since hand-edited is left in place and reported kept (reason user-modified), never overwritten or deleted. Every ledger key is re-validated (path normalization, containment under the scope root, and a symlink-chain check) before it is read or unlinked, so a tampered or corrupt ledger entry refuses (corrupt-ledger) instead of deleting outside the scope root. Removes now-empty parent directories up to (not including) the scope's fixed kind roots (skills/, rules/, agents/, memory/, learning/, data/learning/). --dry-run reports removed/kept/missing without writing; the ledger is updated only on a real run. |
--json on any subcommand prints a structured result matching the table
above, with a fixed, subcommand-specific set and order of top-level fields —
NOT alphabetically key-sorted — but stable (the same fields, in the same
order, for a given subcommand and outcome). A usage error (a missing
required flag, or an invalid --scope/--target-scope/--kind value) exits
2 with a one-line reason on stderr, independent of --json. Every other
refusal from bundle export/import/inspect/verify/uninstall (checksum
mismatch, unresolved conflict, audit failure, a private-dir .gitignore
conflict, a scope mismatch, an unrecorded hook-config opt-in, a corrupt
ledger, …) is one of src/bundle/types.ts's named BUNDLE_REFUSAL reasons
and exits 1. import --external/verify --external-imports use one
additional reason outside that set, corrupt-external-imports-registry,
for a tampered, unverifiable, or case-shadowed external-imports registry.
learn¶
keryx learn observe [--hook claude]
keryx learn extract [--domain <d>] [--since <YYYY-MM-DD>] [--json]
keryx learn list [--status <s>] [--domain <d>] [--scope <s>] [--json]
keryx learn review [<id>] [--scope <s>]
keryx learn accept <id> [--scope user] [--refresh]
keryx learn reject <id> [--scope user]
keryx learn apply <id> --skill <module/name> [--dry-run]
keryx learn promote <id>
keryx learn graduate [--domain <d>]
keryx learn graduate apply <proposal-id>
keryx learn prune [--dry-run] [--json]
The self-learning loop's consent CLI (flow 312, W3): passive observation
(observe), deterministic extraction (extract) into status: candidate
learned-pattern records, human review and consent (review/accept/
reject), and the bounded paths onward from an accepted record (apply,
promote, graduate).
| Subcommand | Description |
|---|---|
observe --hook claude |
Reads one host-hook payload from stdin (bounded), maps it to the same observation-event shape the built-in keryx.learning-observer hook writes, and appends it under .metaproject/data/learning/observations/<date>.jsonl. Always exits 0 and prints nothing to stdout — invalid JSON on stdin is simply not written, never a failure. This is the command an opt-in Claude Code hook runs. |
observe (no --hook) |
Manual/offline use: reports today's observation file's line count. Nothing to flush — the writer is unbuffered. |
extract |
Runs the deterministic signals (repeated correction, reverted edit, failing→passing test, reviewer comments, health regression) — and, only when no deterministic signal fired and the capability is enabled, the optional model-backed extractor — over the observation window, creating or reinforcing status: candidate records. Also decays existing candidate/accepted records. Never writes status: "accepted". |
list |
Lists records, filterable by --status, --domain, --scope. Also prints an integrity warning for any accepted record with no matching accept decision on record (auditAcceptedRecords). |
review [<id>] |
Prints one candidate (by id) or every candidate, with trigger, action, confidence, confidenceLevel and evidence references, for a human to read before deciding. |
accept <id> |
status: candidate → accepted. The ONLY command that produces accepted — refuses outside a real interactive terminal, with no bypass flag or environment variable. For a scope: project record, also writes (or, with --refresh, overwrites only the current project's) entry in ~/.keryx/learning/index.json (see "Cross-project evidence" in the W3 spec). Never writes a skill, rule, agent or memory entry. |
reject <id> |
status: candidate → rejected. No terminal requirement. |
apply <id> --skill <module/name> |
For a domain other than review-conventions: renders the accepted record as a LearningProposal and applies it through the existing applyLearningProposal — still the only writer. |
promote <id> |
Promotes a scope: project, status: accepted record backed by >=2 distinct project identities (each at indexed confidence >=0.8) to a new scope: user, status: candidate record. Interactive-only: refuses outside a terminal, with no bypass flag, and prompts you to type the pattern's id back to confirm. |
graduate [--domain <d>] |
Clusters accepted records by domain and trigger-keyword overlap and writes a graduation proposal — never a SKILL.md, agent definition, or rule file directly. |
graduate apply <proposal-id> |
Applies one graduation proposal. Interactive-only, same as promote: refuses outside a terminal, no bypass flag, and prompts you to type the proposal's id back to confirm. |
prune |
Deletes observation files more than 30 days past their own date, and expires status: candidate records past their ttl.expiresAt with no decision (status: expired). --dry-run reports without writing or deleting anything. |
accept, promote and graduate apply never take a --yes/--force/
--non-interactive flag: this mirrors keryx schedule add/keryx flow
confirm's own rule that an action a human must consciously approve gets no
unattended path. None of the three is reachable through MCP — src/mcp/
tools.ts is a hand-curated allowlist and carries no entry for learn.
commands¶
The agent-facing command registry: each described keryx command as a
machine-readable descriptor, with the natural-language intents that resolve to
it. This is the surface .metaproject/index.md points an agent at so it can
pick the right command from a phrase instead of guessing, and it is the source
of truth the curated intent table in that file is derived from.
Which verbs it covers, and which it does not. The registry is not the full CLI surface, and the difference is deliberate rather than incidental. A verb earns a descriptor when it is a single callable operation with a machine-consumable result. Verbs are excluded when they are:
- interactive or long-running —
shell,sessions,serve,harness; - lifecycle rather than operation —
init,update,rules,orient,sync,mcp,review; - aimed at a human or a maintainer —
dashboard/dash,standard,metrics,skills; - held back behind a security boundary —
workspace, whose offline SAC registry mutation is intentionally local-CLI-only for now, because a descriptor makes a verb eligible for later remote/MCP projection; - the registry itself —
commands.
The exclusions are not a comment: src/standard/command-registry.coverage.test.ts
derives the verb list from CLI_ROUTES and fails when a new verb is neither
described nor excluded with a stated reason, because an exclusion without a
reason is indistinguishable from an oversight. The guard is verb-level; a new
subcommand inside an already-described verb is not detected, which that file
states rather than leaves to be discovered.
Consumers treat the registry as exhaustive over what it covers — the remote maintenance surface projects it and refuses to invoke anything absent from it — so a silent gap is a command an operator cannot reach.
keryx commands # Markdown registry
keryx commands --json # machine-readable descriptors (harness / MCP)
keryx commands --module <name> # filter to one module
keryx commands --intent "<phrase>" # resolve a phrase to the matching command(s)
keryx commands --intents # the intent → command table
| Flag | Description |
|---|---|
--json |
Stable descriptor payload: module, command, summary, intents, arguments, output shape, whether it uses a model. |
--module <name> |
Restrict the output to one module. |
--intent "<phrase>" |
Print the best-matching command(s). Exits 1 when nothing matches, so a caller can branch on it. Combines with --json. |
--intents |
Emit the full intent → command table. |
--help, -h |
Print commands usage and exit. |
dashboard (and dash)¶
Build or open the self-contained project admin dashboard, a single HTML file at
.metaproject/keryx-dashboard.html embedding health, graph, testing, wiki,
and memory snapshots.
keryx dashboard build # rebuild the HTML, print its path
keryx dashboard open # rebuild, then open in the default browser
keryx dash [build|open] # bare `dash` defaults to `open`
| Subcommand | Description |
|---|---|
build |
Rebuild keryx-dashboard.html and print its relative path. |
open |
Rebuild then open the file (platform-aware: open / start / xdg-open). |
dash is a shortcut for dashboard; with no subcommand it defaults to open.
Requires an initialized workspace; an unknown subcommand exits 1.
gdgraph¶
Build a deterministic intra-project import/dependency graph and optionally enrich it with tree-sitter symbols and resolved call edges. Structural queries operate from persisted graph artifacts and degrade to the file graph when the symbol layer is disabled or unavailable.
keryx gdgraph build
keryx gdgraph query <cycles|orphans>
keryx gdgraph find "<terms>"
keryx gdgraph symbol "<name>" [--impact] [--depth N]
keryx gdgraph symbols <enable|disable|status>
keryx gdgraph path "<A>" "<B>"
keryx gdgraph affected <file-or-symbol> [--depth N] [--ranked] [--json] [--data-dir <dir>]
keryx gdgraph repomap [--budget N] [--seed <path>...] [--changed]
keryx gdgraph context
keryx gdgraph assets list | verify [<id>] | pull <id>
| Subcommand | Flags / args | Description |
|---|---|---|
build |
— | Scan the tree, build the graph, write JSONL storage + summary.md/module-map.json, print node/edge counts. Takes the project's maintenance lock (see trigger → Locking): waits up to KERYX_MAINTENANCE_LOCK_WAIT_MS (default 120 s) when another keryx run holds it, then exits 75 naming the holder's pid. |
query cycles |
— | Print dependency cycles (a -> b -> a), or "No cycles found." |
query orphans |
— | Print modules with no resolved inbound or outbound edges. |
find "<terms>" |
— | Rank file paths and available symbols by concept/name match. Directs content searches to ctx rg. |
symbol "<name>" |
--impact, --depth <N> |
Print exact definitions, callers, callees, and wiki pages that document the defining files. Loose matches across different symbol names require disambiguation. --impact adds transitive callers (default depth 3). |
symbols |
enable, disable, or status |
Toggle the tree-sitter capability in the manifest or report capability/symbol/call counts. Enabling is explicit and never downloads assets implicitly. |
path "<A>" "<B>" |
— | Resolve file or symbol endpoints and print the shortest path across import and call edges. |
affected <file-or-symbol> |
--depth <N>, --ranked, --json |
Resolve a file or symbol, print dependencies/dependents, and optionally walk/rank the transitive blast radius. |
repomap |
--budget <N>, --seed <path>..., --changed |
Write a token-budgeted repo map artifact. --budget caps the token estimate, --seed biases toward one or more paths (repeatable), and --changed seeds from locally changed files (git diff --name-only HEAD). |
context |
— | Emit the bounded graph portion of the turn-start orientation block, ending in a freshness line: working tree clean, or N uncommitted code file(s) may not be reflected. |
assets list \| verify [<id>] \| pull <id> |
— | Manage declared assets from assets.lock.json: list shows resolved/missing state, verify checks checksums (exit 1 on mismatch), pull fetches and verifies one asset (the only networked verb). |
Only the exact queries cycles and orphans are accepted; anything else exits
1. affected with no file argument prints usage and exits 1.
Freshness. Every query answers from the last build, never from the working
tree, and a stale answer is shaped exactly like a fresh one. Adding, renaming,
deleting or moving a file invalidates the node set; adding or removing an import
invalidates the edge set, so affected under-reports. An edit inside a file with
unchanged imports leaves the file graph correct and the symbol layer stale.
Rebuild with build — before relying on an answer, not once per question — and
let the gdgraph post-commit hook cover the committed half. The full contract is
in .metaproject/modules/gdgraph.md (Freshness & Refresh).
ctx¶
Token-aware wrapper that runs common developer commands and reads files, printing
a compact Markdown summary while persisting the full raw output under
.metaproject/data/gdctx/.
keryx ctx status
keryx ctx diff [git-diff-args...]
keryx ctx rg "<pattern>" [path]
keryx ctx read <file> [--mode outline|compact|full]
keryx ctx run [--raw] -- <command...>
keryx ctx show [latest|<name>] [--raw]
keryx ctx install-hook [--runtime <id|all>]
keryx ctx uninstall-hook [--runtime <id|all>]
| Subcommand | Flags / args | Description |
|---|---|---|
status |
— | Report metaproject/manifest/config/data presence and whether gdctx is enabled. |
diff |
git-diff args (e.g. --staged, --stat) |
Run git diff <args> and summarize (files, risk hints, hunks, errors). |
rg |
"<pattern>" [path] |
Run ripgrep and summarize top files + example matches. Requires ≥1 arg. |
read |
<file>, --mode outline\|compact\|full |
Read and summarize a file. Default mode compact. |
run |
[--raw] -- <command...> |
Run an arbitrary command after -- and summarize its output. Errors if empty. --raw (before the --) streams stdout/stderr unmodified and returns the command's exit code, with no summary, redaction or artifact: use it for machine artifacts (keryx ctx run --raw -- keryx review scope --json > scope.json). |
show |
[latest\|<name>], --raw |
Print a saved artifact summary (.md), or the raw .log with --raw. |
install-hook |
--runtime <id\|all> |
Install an opt-in routing guard that blocks broad raw search/read/diff commands and points the agent to the bounded ctx equivalent. |
uninstall-hook |
--runtime <id\|all> |
Remove only the managed routing-guard integration for the selected runtime(s). |
wiki¶
Manage the local, Markdown-on-disk project knowledge base under
.metaproject/wiki/ (architecture, domain models, business rules, decisions, and
more), including auto-collected drafts from other modules' data.
keryx wiki status
keryx wiki new <type> <slug> --title "<title>" [--force]
keryx wiki collect [--force] [--changed [--since <ref>]] [--limit <n>]
keryx wiki index
keryx wiki check-links
keryx wiki validate
keryx wiki ask "<question>" [--k <n>] [--rerank]
keryx wiki enrich [<page>|--all] [--force] [--list] [--resume] [--limit <n>] [--concurrency <n>]
[--provider <p>] [--model <m>] [--dry-run] [--json]
keryx wiki context
keryx wiki backlinks <wiki-page-or-code-file>
keryx wiki freshness
keryx wiki refresh
keryx wiki verify --page <path> | --baseline
keryx wiki migrate-markers
keryx wiki history <page> [--json] | keryx wiki history --runs [--json]
keryx wiki restore <page> [--version vNNNN] [--json] | keryx wiki restore --run <run-id> [--force] [--json]
keryx wiki sections list [--json]
keryx wiki sections resolve <section-ref> [--json]
keryx wiki sections sync [--dry-run] [--accept-reoccupation <ref>[,<ref>...]] [--json]
keryx wiki sections migrate [--dry-run]
| Subcommand | Flags / args | Description |
|---|---|---|
status |
— | Show enabled state, root, total pages, per-type counts, last index/link-check state, and any pages carrying a leaked <think>/<thinking> tag outside a fenced code block or inline code span — one shown as documentation (e.g. a fenced example) is never flagged (with a hint to re-run enrich --force on each). |
sections list |
--json |
List every indexed section with its identity, its stability, and the page it belongs to. |
sections resolve |
<section-ref>, --json |
Resolve a section reference. Answers found, page-found, tombstoned, reoccupied, pending-tombstone, stale-locator, registry-unreadable or unknown, and never redirects a deleted identity to a same-named section elsewhere. A found identity that was once removed also prints its removal history and the basis on which its tombstone was lifted — byte-identical (a genuine restoration) or accepted-substitution (an operator accepted a different document at that address). Exits 0 when live, 1 for any answer about a dead identity, 2 when the registry cannot be read. |
sections sync |
--dry-run, --accept-reoccupation <ref>[,<ref>...], --json |
Rebuild the section index from the pages on disk: register the current stable identities and tombstone the ones that disappeared. The only command in this area that writes. Exits 1 when any identity is reoccupied — removed, then re-minted at the same address by a different document — because a tombstone and a live document claiming one address is a contradiction, not a completed sync. --accept-reoccupation is the only exit from that state: it lifts the named tombstones and records permanently that the content was substituted rather than restored, which sections resolve then reports on every read. Accepting a page ref also accepts the sections inside that page. |
sections migrate |
--dry-run |
Insert versioned identity markers into pages that have none. Content is preserved byte for byte apart from the markers, and the round trip is asserted before anything is written. |
new |
<type> <slug>, --title "<t>", --force |
Scaffold a page from template. Refuses to overwrite unless --force. |
collect |
--force, --changed, --since <ref>, --limit <n> |
Generate a hierarchical, full-coverage draft scaffold from graph/health/testing data, rebuild the index, and report the remaining draft-enrichment work front. --changed can scope collection to changes since a ref. On an existing draft, --force regenerates only the generator's sections — ## Reference and ## Related Wiki (keeping lines an enricher added there), and a map page's data sections only while that page was never enriched — and keeps front matter, prose and changelog, adding one changelog entry and a Version bump. A page that would still lose a changelog entry, front-matter key or managed block is left alone and the command exits 1. |
index |
— | Rebuild the managed page-index block in wiki/index.md. |
check-links |
— | Validate internal Markdown links; write a report. Exits 1 if any broken. |
validate |
— | Metadata + link + index-staleness checks (superset of check-links). Also reports Version lower than the newest changelog entry, and — against the page's previous version in its history — a dropped ## Changelog, a removed or reworded changelog entry (attestations included), a dropped front-matter key or managed block, and a duplicated section. Only a change keryx recorded is judged this way: a page edited outside keryx since its last recorded version is not (it may be deliberate, and nothing records it until keryx next writes the page), and a page with no history yet gets only the Version check. An unreadable page history is reported as an issue of its own. Exits 1 on issues. |
ask "<question>" |
--k <n>, --rerank |
Answer a question from the local wiki with a deterministic, citation-backed retrieval pass over the pages. --k caps the number of retrieved passages; --rerank applies the extra reranking step. |
enrich [<page>] |
--all, --force, --list, --resume, --limit <n>, --concurrency <n>, --provider <p>, --model <m>, --dry-run, --json |
Needs a model credential. Fill draft pages with model-written prose; defaults to drafts only and validates. It never changes a page's Status, front matter or changelog: those come back from the original after the model's turn, one changelog entry (Prose rewritten by keryx wiki enrich) and a Version bump are added, and a reply wrapped in a ```markdown fence is unwrapped first. A page that would still drop a managed Reference block is not written (failed, reason invariant: …). Strips a complete <think>/<thinking> block out of model output before it reaches a page, and rejects (does not write) content that still carries a stray, unclosed tag — both checks ignore a tag shown inside a fenced code block or inline code span, so a page documenting this guard with a <think> example keeps it intact instead of having it stripped or the whole page rejected. Supports optional RLM mode via .metaproject/wiki.config.json (set rlm.enabled: true): classifies pages as skip/light/deep based on staleness and graph metrics; deep pages receive a graph-aware model call; batching and staleness-skipping apply automatically; budget-exhausted pages fall back to the template. The exception among the model commands: without a credential it exits 0 and marks the affected pages skipped rather than failing. |
context |
— | Emit the bounded wiki-index portion of the turn-start orientation block. |
backlinks <target> |
— | For a wiki page or code file, print wiki pages linking to the target and graph dependents when the target is a graphed code file. |
freshness |
— | Read-only backlog: which pages the code has moved under since each was last verified, classified and ordered by how far behind. Writes only its own report, never a page. Always exits 0 — a report, not a gate. |
refresh |
— | Writes pages. Deterministic, model-free regeneration of the managed ## Reference blocks from the graph. Prose is never touched. |
verify |
--page <path> | --baseline |
Stamp provenance (VerifiedAt, VerifiedScope) without touching content. Refuses to run bare: stamping every page silently would assert a review that did not happen, so the whole-corpus form must be asked for by name. |
migrate-markers |
— | One-off, idempotent: wrap pre-existing ## Reference sections in the managed markers refresh needs. Authors no content. |
history |
<page>, --runs, --json |
Print a page's version index (every version: when, which command and run, sha256, link to the stored copy; first row is current), or with --runs the runs that changed pages, newest first. Read-only. |
restore |
<page>, --version vNNNN, --run <id>, --force, --json |
Put a page back to a recorded version — by default undoing its last change (a hand edit back to the last recorded version, otherwise the version before the current one) — or with --run undo every page one run changed: changed pages go back, created ones are deleted, deleted ones return. A page changed again after that run is reported as a conflict and left alone unless --force (exit 1 on conflicts). The restore is itself recorded, so it can be undone the same way. |
Page history. Every command that writes or deletes a wiki page or index.md —
new, index, collect, enrich, refresh, verify, migrate-markers, restore,
keryx sync --apply and the SAC wiki owner-writer — first records the page's exact
current bytes as a plain .md file in .metaproject/data/gdwiki/history/<page>/, next
to an index.md listing the versions, and prints the run id to undo with. A failure to
record leaves the page untouched. Retention is the newest 20 stored versions per page
(history.keep in .metaproject/wiki.config.json); each page's index keeps at most 200
rows and runs.jsonl is trimmed to its newest half past 2 MB. The folder ignores itself
for git (a .gitignore of * at its root) besides keryx's managed block. A version file
named in an index.md is only used if it has the shape keryx writes and resolves inside
that page's folder. Not covered: an edit made outside keryx (by hand, or a script writing
pages directly) is captured only when the next keryx write to that page finds it, so two
such edits in a row keep only the last; history is local to the machine, and deleting
.metaproject/data/ deletes it; .sections.json and templates/page.md have none.
VerifiedAt records that the code in a page's scope has not moved since that
revision — not that the page was ever correct. A page can be wrong from the day
it was written and nothing here notices. The
freshness guide is the operator-level tour.
Page types: architecture, domain-model, business-rule, user-scenario,
component, service, integration, decision.
When the security module is enabled, collect runs an advisory security check
before writing each draft. Advisory (the default) reports and writes anyway;
enforced/ci/gateway mode can suppress a draft's write with a masked reason.
skills¶
Manage the working-skills subsystem: a bundled catalog of skills and per-project skill packages, plus routing, verification, learning, export, and JSON contracts.
keryx skills status
keryx skills list
keryx skills inspect <project-skill>
keryx skills route <query-or-target>
keryx skills catalog [--profile recommended]
keryx skills install [--profile recommended]
keryx skills install --profile <manifest-profile> [--with <component>]... [--without <component>]...
[--target <harness>] [--include-deprecated] [--dry-run] [--json] [--force]
keryx skills doctor [--target <harness>] [--json]
keryx skills uninstall --target <harness> [--module <module-id>] [--force] [--json]
keryx skills create <target> --module <module> --name <skill-name>
keryx skills import --from <dir|SKILL.md|https-url> [--module <module>] [--name <name>]
[--only <glob>]... [--dry-run] [--force] [--allow-flagged] [--json]
keryx skills update [<module>/<name>|--all] [--from <origin>] [--dry-run] [--allow-flagged] [--json]
keryx skills remove <module>/<name> [--dry-run] [--json]
keryx skills verify <skill-or-target>
keryx skills verify --bundled [--root <dir>] [--json]
keryx skills learn --from-review <path> --skill <module>/<skill>
keryx skills learn apply <proposal.json>
keryx skills export <project-skill> --runtime codex|claude|plugin
keryx skills sync --runtime codex|claude --target <dir>
keryx skills contracts validate <file> --schema <name>
keryx skills scout <name-or-description> [--record <pack-dir>] [--include-imports] [--candidate <dir>] [--scope bundled|all]
[--origin learned --source-ref <id>] [--json]
keryx skills eval <skill-id> [--strictness low|medium|high] [--trials N] [--runner <provider>[:<model>]]
[--judge <provider>[:<model>]] [--scope bundled|all] [--model-grader] [--json]
keryx skills judge-check <skill-id> --judge <provider>[:<model>] [--scope bundled|all] [--samples <n>] [--record] [--json]
keryx skills stocktake [--scope bundled|all] [--quick] [--json]
| Subcommand | Flags / args | Description |
|---|---|---|
status |
--json |
Print the local gdskills install status summary. |
list |
--json |
List registered project skills as a table. |
inspect <project-skill> |
--json |
Inspect one project skill: metadata + file presence. Missing target exits 1. |
route <query-or-target> |
--json |
Score/rank registry entries against a free-text query or path. |
catalog |
--profile minimal\|recommended\|full\|custom |
Print the bundled catalog for a profile. |
install |
--profile <profile> |
Install bundled skills, catalog, manifest, and contracts. Requires .metaproject/. |
install --profile <manifest-profile> |
--with <component> (repeatable), --without <component> (repeatable), --target <harness>, --include-deprecated, --dry-run, --json, --force |
Flow 325 (W1): resolve and apply a profile→modules/components install-manifest plan instead of the legacy profile copy. Triggered by any manifest flag, or a non-legacy --profile id (e.g. core, react, nestjs, python) — the four legacy ids (minimal\|recommended\|full\|custom) with no manifest flags still run the pre-309 behavior above. --dry-run prints the resolved plan without writing, and its Apply this plan: hint repeats every --with, --without, --include-deprecated, --target and --profile that was passed. A --with/--without id that is unknown, or that cannot change the plan for the chosen profile (judged by effect: the id must change the set of modules the profile selects, so --profile full --without framework:react and --profile react --with framework:react are refused as no-ops), exits non-zero and lists the ids that would change the plan. Reads .metaproject/data/stack/stack.json (from keryx stack detect) when present to bias module selection; absent or unreadable fails open. --force overwrites drifted files; without it, a drifted/unrecorded existing file is skipped, not overwritten. v1 install destinations exist only for --target claude and --target keryx-shell. |
doctor |
--target <harness>, --json |
Compare the recorded install-state for a target against disk: ok/drifted/missing/orphaned per path. Exits 1 if anything is not ok. |
uninstall |
--target <harness> (required), --module <module-id>, --force, --json |
Remove only the paths recorded in install-state for a target, optionally scoped to one module. A drifted file is refused unless --force. |
create <target> |
--module <m>, --name <n>, --format auto\|single\|package, --dry-run |
Create and register a project-skill package. (generate is an alias.) |
import --from <src> |
--module <m>, --name <n>, --only <glob> (repeatable), --dry-run, --force, --allow-flagged, --json |
Copy a SKILL.md (directory, file, or https GitHub blob/raw URL) into .metaproject/project-skills/<module>/<name>/, recording Origin. --module review is the only module review-orchestrator auto-dispatches. --only is a glob (*, ?) matched against the package directory name as it is on disk; it is required when --from is a tree of several packages and the import targets module review — the import is refused with the candidate list until it is given. An --only with an empty value is an error. In a tree, --name likewise selects by the directory name. What is written is the slug of that name (review_house_api → review-house-api): the destination, the already-exists and bundled-name checks and the reported row all use the slug, and two selected directories that slug to one destination are refused before anything is written. In a tree import a deprecated: true package is skipped. Other hosts and GitHub tree URLs are refused. A bundled name is skipped unless --force. A package landing in module review gets warning: lines for: no path trigger (paths: none), each metadata.flags entry dropped as not a flag, each flag exactly one existing project reviewer carries (it becomes a family flag for both), and — on a --force overwrite — each family flag the new version drops that leaves exactly one other reviewer carrying it (that reviewer is dispatched outright under it from then on). A name with no letter or digit (a package directory, or a SKILL.md or URL name) is refused. Before anything is written, every destination — package, registry, catalog, each rule, each template — that would be written or compared is checked for a symlink on the way that resolves outside the project; one is refused and nothing is written, --dry-run included. A template destination that is only reported (skipped) is not written and does not refuse the run. Frontmatter is read as a YAML subset (BOM and CRLF accepted, trailing # comments dropped, metadata.paths/metadata.flags as a flow, block or comma-separated list, only keys directly under metadata:); a shape outside it reads as not declared. Rules the skills cite are copied as described under review import. A package's templates/ directory (a report overlay, say) is copied byte for byte, subdirectories included, to .metaproject/project-skills/<module>/<name>/templates/, so the skill reads it from the project and not from the repository it was imported from; a URL source has none. Only regular UTF-8 text files are copied (a symlink or a binary file is not; the latter gets a skipped row). Each file has its own row, in a templates array in --json: imported (would-import on a dry run), unchanged when the project already has it byte for byte, differs when the project's copy is another file — left as it is, and replaced (overwritten, would-overwrite) only with --force, or by keryx skills update, which refreshes the package from its origin. Running import a second time therefore skips what the source has not changed and reports what it has as differs. A package that is already in the project still gets the templates it lacks. A template that cannot be written because a file (or a link to one) stands where its directory belongs, or because a directory stands where the file belongs, is skipped with the path named, in a dry run and a real run alike; the rest of the package is written. Every file is passed through the security gate on its final text while the import is planned, --dry-run included. A secret is masked and the row says redacted by the security gate (…) (would be redacted … on a dry run); another finding is reported on the row as flagged by the security gate (…), following the project's security mode. A prompt-injection finding refuses the file in every mode: the row is refused (would-refuse on a dry run) with the gate's own summary, nothing is written, and the other packages go on. A template the gate refuses refuses its whole package: the package row is refused (would-refuse), its other templates are skipped, and neither SKILL.md, the registry entry, the catalog row nor any template is written; other packages of the same run are not affected, and a rule cited only by the refused package is not written. A package the project already has keeps its row (skipped) and its rules without --force; only its templates are withheld. With --force the package is being written, so its row is refused, its installed SKILL.md stays, and the rules only it cites are skipped. The refusal is reported before a differs verdict, so --force is never advised for a file that would then be refused. With the security module disabled the import runs the deterministic injection detector itself and refuses on a match the same way (the reason says the security module is disabled and the import ran its own injection check); a secret is still masked and clean text imports as usual. A rule cited only by refused packages is not written: its row is skipped — cited only by a refused package. A real run with any refused row prints every row and then exits 1; a dry run (would-refuse) exits 0. The final text is gated once, before the scaffold, registry entry or catalog row is written, and the text written is the text gated. --allow-flagged writes an injection-flagged file after you have read it (the row says it was flagged and written because of the flag); it does not override a block by the project's security mode, so under enforced a secret is refused either way. The --allow-flagged hint under the rows is printed only when a refused row carries a prompt-injection finding. A row carries a security object in --json (action, findings[] of policyId/category/action, redacted, refused). |
update [<module>/<name>] |
--all, --from <origin>, --dry-run, --allow-flagged, --json |
Re-read Origin and overwrite SKILL.md when the source moved on. Name one skill, or --all. A skill with no Origin is skipped. The templates/ directory beside a local Origin is re-read with it: each file replaces the project's copy and a file the source gained is added, through the same gate, containment check and templates rows as import; a file the source dropped stays. A refused template refuses its package here too: the installed SKILL.md and templates stay as they were. A destination behind a symlink that resolves outside the project is refused before anything is written, --dry-run included. The refreshed text goes through the same security gate as import: a prompt-injection finding makes the row refused (would-refuse on a dry run) and leaves the installed copy as it was, unless --allow-flagged; the text is gated before anything is written, so a refusal never touches the installed SKILL.md, registry or catalog. With the security module disabled the update runs the injection detector itself, as import does. A real run with any refused row prints every row and exits 1; a dry run exits 0. A secret is redacted and reported on the row; rows carry a security object in --json. |
remove <module>/<name> |
--dry-run, --json |
Remove a project skill — the inverse of create/import, in this order: the verification report, the catalog row, the package directory (its imported templates/ with it, and its <module>/ directory when it was the last skill there), and the projectSkillRegistry entry last. Each part is listed as removed or absent; an already-missing part is not an error, and a skill whose entry and package are gone is still found by a leftover catalog row or verification report, so a skill half-removed by hand can be finished. If a step fails (e.g. a read-only directory), the error names that part and lists what is already gone and what is still in place (a package directory whose removal failed part-way is listed as (may be partly removed)); the registry entry is still there, so re-running the same command after fixing the cause finishes the removal. --dry-run lists what would be removed and changes nothing. Refused with exit 1, always before anything is changed: a bundled skill (use install/uninstall); an unknown name (names are case-sensitive: Review/Alpha is refused, naming the registered review/alpha, even on a case-insensitive filesystem); a skill whose package directory is on disk only under another spelling (review/Alpha/ for review/alpha) — the refusal names the on-disk spelling; rename the directory to the registered one and retry; a registry entry whose path is not its own .metaproject/project-skills/<module>/<name> or whose module/name is not a plain path segment; a symlink where something would be deleted (project-skills/, the <module>/ directory, the package itself, the reports directory) — nothing is deleted through a link and the link is not unlinked; a metaproject.json or catalog.md that resolves outside the project (one reached through a symlink inside the project is rewritten in place and listed with its resolvedPath). A verification report whose body names another package (skillPath) is left alone even when its file name matches. Imported rules, runtime exports and learning proposals are left in place. |
verify <skill-or-target> |
--dry-run, --json |
Verify a project skill against evidence; write a report. --all verifies every registered skill. |
verify --bundled |
--root <dir>, --json |
Structurally validate the shipped skill tree (the 65 SKILL.md files copied into every install), not this project's project-skills. Exits 1 on any finding and on an empty tree. |
learn --from-<source> <path> --skill <m>/<s> |
--from-review\|--from-test\|--from-failure\|--from-health\|--from-memory <path>, --skill, --dry-run, --json |
Create an auditable learning proposal (does not mutate SKILL.md). |
learn apply <proposal.json> |
--dry-run, --json |
Apply a reviewed proposal to SKILL.md + changelog; bump patch version. |
export <project-skill> |
--runtime codex\|claude\|plugin, --dry-run, --json |
Export a project skill to a runtime artifact. The plugin runtime (alongside codex and claude) emits a Claude Code plugin package. |
sync |
--runtime codex\|claude, --target <dir>, --dry-run, --json |
Sync exported runtime skills to an explicit target dir. Requires both --runtime and --target. |
contracts list |
— | Print name/path/description for all contract schemas. |
contracts validate <file> |
--schema <name> |
Validate a JSON file against a named contract schema. Exits 1 on failure. |
scout <name-or-description> |
--record <pack-dir>, --include-imports, --candidate <dir>, --scope bundled\|all, --json |
Flow 325 (W1): pre-creation dedupe gate — does an existing skill already cover this? Scores the query against the catalog (bundled by default, --scope all includes project-skills); --candidate vets a not-yet-created skill directory; --record persists the scout result under a pack directory. --include-imports (flow 313, W4) additionally scores the query against every recorded entry in ~/.keryx/skills/external-imports.json (see bundle import --external), with the same lexical scorer, reporting searched: false and a named reason when the registry is absent or corrupt rather than a silently empty match list. |
eval <skill-id> |
--strictness low\|medium\|high, --trials N, --runner <provider>[:<model>], --judge <provider>[:<model>], --scope bundled\|all, --model-grader, --json |
Flow 325 (W1): behavioral compliance eval — trigger accuracy + scenario pass rate. Scenarios that need a runner capability are reported not-run, not failed, when none is configured. --runner splits at the first : into provider and optional model (e.g. --runner deepseek:deepseek-chat, --runner ollama:llama3.1:latest); flow 314 wires this through src/commands/model-eval-runner.ts, dispatching each scenario as a single-turn call with the skill's SKILL.md as the system prompt. --scope (default all for eval, unlike scout/stocktake/judge-check, which default to bundled) selects which catalog the skill id resolves against and the triggers are scored against — a stack-pack eval gate document requires --scope bundled explicitly; the pack gate refuses a report recorded with scope: "all". Flow 316: --judge <provider>[:<model>] (same provider[:model] split as --runner, via src/commands/model-eval-judge.ts, called at temperature: 0 for the closest a provider gets to deterministic scoring) grades every "grader": "judge" behavior scenario with a live LLM judge instead of leaving it skipped; the judge is a separate build and separate calls from the runner under test, even when both name deepseek:deepseek-chat. Building either an unknown --runner/--judge provider, or one with no credential, fails closed before any scenario runs. With --judge, the report gains judge (provider), judgeModel, and judgePromptVersion (the judge prompt version that produced the recorded verdicts); every report also gains catalogDigest (the bundled catalog the trigger scenarios were scored against, kept as informational context only — trigger scenarios are always re-scored live against the current catalog, never trusted from the report) and, per ran behavior scenario, trialRecords — one entry per trial (output, outputSha256, deterministic results, judge verdict, passed) so the report can be re-derived (regradeRecordedReport) without re-running the model. The stable-pack gate (checkSkillReportForPackGate) requires the report's (runner, model) and, when any scenario carries a judge expectation, its (judge, judgeModel) to both be in the gate's allowlisted set (STACK_PACK_GATE_POLICY, pinned to deepseek/deepseek-chat for both roles), judgePromptVersion to match the current judge prompt, and the recorded passes/passRate/trial counts to match what the trial records themselves show — a report from an unlisted runner or judge, a stale prompt version, or counts that disagree with its own records, never clears the gate. What this proves, and what it does not: the gate proves internal consistency — the recorded outputs, deterministic results, counts and pass rates are re-derived from the trial records, and trigger results are re-scored live, not re-declared from the report. It does not prove provenance: the trial outputs, judge verdicts, and runner/judge labels are self-declared by whoever ran the eval, and the gate never re-runs the judge model against them ("re-derive"/"re-grade" here means recomputing from what was recorded, not re-judging). The control for provenance is review of the committed eval.json / recording diff in the pull request. |
eval --reverify <pack-dir> |
--judge <provider>[:<model>] (required), --sample <n> (default 10), --json |
Flow 317 (FU3): the live re-judge sampler. Re-judges a RANDOM SAMPLE of <pack-dir>'s already-recorded trial outputs (governance/eval.json, every ran behavior scenario's trialRecords that carry a judge verdict) against the CURRENT live judge, matched against the skill's current evals.json by scenario id, and reports where the live verdict disagrees with what was recorded. Read-only — never rewrites governance/eval.json. Threat model: eval's own stable-pack gate (above) proves a recorded report is internally consistent (regradeRecordedReport) and re-scores triggers live, but it cannot prove a recorded JUDGE verdict was ever a genuine live grading rather than, say, a hand-edited recording — nor can it catch the judge PROVIDER's own weights drifting under a pinned model name over time. --reverify is the closest available check for exactly those two gaps: a diagnostic a human (or a scheduled job) runs and acts on, not something the gate itself enforces automatically. Exits 1 when the sampled disagreement rate exceeds REVERIFY_DISAGREEMENT_THRESHOLD (20%, src/gdskills/governance/eval.ts) — deliberately generous, since a live judge is not perfectly deterministic on identical input (flow 316 review round 1) and a single flaky call among a handful of samples is expected noise, not drift. |
judge-check <skill-id> |
--judge <provider>[:<model>] (required), --scope bundled\|all (default bundled), --samples <n> (default 3), --record, --json |
Flow 316: proves a skill's judge-graded scenarios are hard to game. For every scenario carrying a "grader": "judge" expectation, runs the canned answer set — empty, echo, vague, known-wrong, subtle-wrong, injection, stuffed, known-right (eight kinds; fix 1 / R1-4, R1-11 added vague and subtle-wrong, authored per scenario from the required calibration.vague/calibration.subtle_wrong fields) — through the live judge named by --judge, using the same grading function eval's own trial loop uses. Each non-empty canned answer is graded --samples times (default 3) rather than once, because the live judge is not deterministic on identical input; a canned answer is a match only when every sample agrees with the expected verdict and none errored — the recordings this command writes (with --record) store all of a canned answer's samples, not a single verdict. Exits 1 if any canned answer's verdict does not match what it should be (empty/echo/vague/known-wrong/subtle-wrong/injection/stuffed must all FAIL, known-right must PASS). --record writes the recorded samples to src/gdskills/governance/judge-recordings/<pack>__<skill>.json, keyed by a digest of the exact judge prompt — the integrity guard replays these offline, and a stale recording (an edited rubric, a bumped judge prompt version) is detected rather than silently reused. A recording is a hand-writable file like any other committed artifact: it proves the anti-gaming set was checked against a live judge at record time, not that nobody could have edited it afterward — the same threat model as eval's report, above. |
stocktake |
--scope bundled\|all, --quick, --json |
Flow 325 (W1): periodic catalog health check — buckets each skill keep\|improve\|update\|retire\|merge. --quick skips the slower checks. |
Profiles: minimal, recommended (default), full, custom. Contract schemas:
subagent-result, subagent-dispatch, agent-event, orchestrator-state,
review-finding.
What verify --bundled checks, and what it does not¶
--bundled is layer one of three and only layer one. It runs the checks that
can be decided by reading files:
| Check | Rule |
|---|---|
frontmatter:block / :name / :description / :metadata |
Required fields are present, non-empty, and metadata carries a version. |
frontmatter:name-unique |
No two skills declare the same name; a harness registers by that name. |
catalog:registered |
BUNDLED_GDSKILLS names the directory, so skills install actually copies it. |
model:concrete-declaration |
No skill names a model id where a model_tier belongs. |
persona:name / persona:marker / path:personal-home |
No shipped file names a particular person or points into their home directory. |
xref:skill / xref:path |
Every skill and every skills/, rules/, scripts/ path a skill names actually exists in the shipped tree. |
It does not judge whether a skill's instructions are correct, useful, or
followed. That is layer two (a model judging across named dimensions) and layer
three (reliability over repeated runs), and neither is built. A clean report from
--bundled is not a quality claim, and the command's own output says so.
The identical sweep runs in CI as src/gdskills/bundled-eval.test.ts, over the
same evaluateBundledTree predicate, so the guard and the command cannot
disagree.
skill-verify-skill¶
Top-level alias for skills verify — verify a project skill against current repo
evidence and write a verification report.
Accepts the same flags as skills verify (--dry-run, --json, --all).
health¶
Aggregate code-quality signals from multiple tools (ESLint or oxlint, TypeScript, tests, dependency audit, SonarQube, plus built-in complexity/coverage/churn) into per-scope health scores, compare against a baseline, and evaluate a pass/warn/fail quality gate.
keryx health run [--strict] [--scope <sel>] [--changed [--since <ref>]] [--source <list>]
keryx health status
keryx health gate [--strict-warn]
keryx health sources
keryx health explain <file-or-module> [--narrate] [--provider <p>] [--json]
keryx health baseline update [--scope <sel>]
keryx health trend [--scope <key>] [--limit <n>]
| Subcommand | Flags / args | Description |
|---|---|---|
run |
--strict, --scope project\|module:<name>\|file:<path>, --changed, --since <ref>, --source eslint,typescript,... |
Run the full pipeline, write latest.json/latest.md + history, print gate + score. Exit 1 if gate = fail. |
status |
— | Read the last report: enabled, last run, gate, project score, regressed scopes, per-source status, trend. |
gate |
--strict-warn |
Re-read the last report's gate (no re-run). Exit 1 on fail, or on warn with --strict-warn. |
sources |
— | Detect and list each source's mode/required/status without running the tools. |
explain <file-or-module> |
--narrate, --provider <p>, --json |
Print a scope's metrics + its first 20 findings from the last report. --narrate adds a model-written explanation and needs a credential — without one it exits 1. Note it returns 0 before reaching the model when the scope has no metrics yet; run keryx health run first. |
baseline update |
--scope <sel> |
Write current scores into the baseline (all scopes, or those matching the selector). Runs health first if no report exists. |
trend |
--scope <scope-key>, --limit <n> |
Print a scope's health-score trend over history. Defaults: scope project, limit 20. |
Lint is a capability, not a tool name (flow 352, AC1–AC3). health runs the
linter the project actually has: ESLint is detected from its config files, oxlint
from .oxlintrc.* or a package.json dependency, and each parses its own JSON
output into findings tagged with the tool that produced them. sources.eslint.required
means "this project is linted" — so when oxlint runs and parses, a skipped ESLint
no longer blocks the run (the gate says NOTE: eslint skipped; lint capability
provided by oxlint instead of INCOMPLETE). A project with neither linter keeps
the previous required-ESLint INCOMPLETE, and a half-installed one (config present,
binary absent → missing) still blocks.
required on a linter therefore means the capability, not the tool: any source
whose adapter declares the lint capability satisfies it, and there is no setting
for "must be linted by ESLint specifically". oxlint is a default optional source,
so a configuration that switches ESLint off and uses neither linter now reports
coverage: partial (lint is genuinely unmeasured there) where it used to report
complete; the gate status does not change. A linter excused because another
one ran gets no OPTIONAL: … skipped line, so the reasons agree with coverage.
A linter left out by --sources is never excused: a filtered run does not
report a clean gate.
The baseline records which sources it measured. A source that starts being
measured after the baseline was taken — oxlint on a project that already named
it, for example — adds findings, but they are new measurement of unchanged
code, so they are left out of regression_score and trend and cannot fail
the gate as a regression; health_score still counts them (a coverage report
seen for the first time is treated the same way). The gate names them —
NOTE: not in the baseline yet, so not compared for regression: oxlint — on
every run until you run keryx health baseline update; from then on the new
source is compared like any other. health run never rewrites an existing
baseline. "Measured" means shaped the scores: sources that produced a
result, plus coverage whenever its data was applied, whatever coverage's mode.
A baseline taken before sources were recorded is read as every source keryx
had before oxlint. keryx health baseline update refuses a report from a
--sources run, and a module/file update while the report measured a source
the baseline has not recorded (update the whole project's baseline first).
A missing source names what it looked for (flow 352, AC5). eslint, oxlint
and typescript now report e.g. tsconfig.json found, binary not found
(node_modules/.bin/tsc and PATH), so the fix is visible in latest.md instead of
requiring a guess about whether the tool or the config was absent. oxlint's reason
names the signal that fired: .oxlintrc.* found, or oxlint named in
package.json. The TypeScript source resolves tsc from the project's own
node_modules/.bin before PATH, so a repository whose compiler is a
dev-dependency is checked by that compiler; that behaviour predates flow 352,
which pins it with a test that puts a decoy tsc on PATH (AC4).
The tests source (flow 353, AC6). In auto mode, keryx health run
used to report the tests source as missing whenever no persisted
.metaproject/data/testing report existed to import — even on a tree where
bun was on PATH and test files existed, which keryx health sources'
own detect-only view had always correctly called available. The two
views now agree: no persisted report is available/not-run (nothing was
executed that pass — bun test is still never run as a silent side effect
of the default auto mode, unlike ESLint/TypeScript, which have no
narrower default scope), never missing. A genuinely missing source
(e.g. no bun binary at all) now also names which check failed, wherever
the gate reports it.
test¶
Discover the project's test context and run its existing test runner, normalizing
output into JSON + Markdown reports under .metaproject/data/testing/.
keryx test init
keryx test analyze
keryx test run [--changed] [--since <ref>] [--strict] [--scope <path>] [--kind <k>]
keryx test status
keryx test context
keryx test report latest [--json]
keryx test related <file>
keryx test suggest <file> [--provider <p>] [--model <m>] [--json]
keryx test explain <file-or-scope>
keryx test coverage-map build|status
| Subcommand | Flags / args | Description |
|---|---|---|
init |
— | Alias of analyze (same code path). |
analyze |
— | Scan the tree, detect the test stack, write context.{json,md} + recommendations.md. |
run |
--changed, --since <ref>, --strict (alias --gate), --scope <path>, --kind unit\|integration\|e2e\|smoke |
Select tests, run the runner, parse output, write the report. Exit 1 on fail/error. |
status |
— | One-line summary: enabled, frameworks, test-file count, last run + status. |
context |
— | Print saved context + recommendations (hints to run analyze if absent). |
report latest |
--json |
Print the latest normalized report (Markdown, or raw JSON with --json). |
related <file> |
— | List tests related to a source file by naming/directory heuristics. |
suggest <file> |
--provider <p>, --model <m>, --json |
Needs a model credential. Propose a test plan for the file, matching the frameworks already detected in the project. Exits 1 without a credential. |
explain <file-or-scope> |
— | Frameworks + related tests + latest failures filtered by the target. |
coverage-map build |
— | Build the test-impact coverage map (source → covering tests) and write the artifact. Prints the source strategy and entry count. |
coverage-map status (default) |
— | Report the coverage-map capability + config state, whether a map is present, its gitRef, and whether it is stale (a stale map falls back to static selection). Bare coverage-map defaults to status. |
--changed selects tests for changed files (via git); with --strict and no
matched tests, the run fails — this drives the pre-push gate.
When the opt-in testing coverage-map TIA capability is enabled (see
init --testing-tia) and a fresh map exists, run --changed prefers the
coverage map to pick precisely the tests that cover the changed sources; it falls
back to the static naming/directory heuristics when the map is missing or stale.
The smoke tier (--kind smoke) selects the fast smoke subset.
When the security module is enabled, run runs an advisory security check on the
captured raw log before persisting it. Advisory (the default) reports and still
writes the log; enforced/ci/gateway mode can suppress raw-log persistence
with a masked reason (the run itself is never broken).
stack¶
Deterministic, offline stack detection (flow 325, W1). Reads manifest files
(package.json, pyproject.toml, go.mod, Cargo.toml, …), marker files
(Dockerfile, .github/workflows/*.yml, *.tf, *.sql, …) and runs a
bounded extension scan — no network, no model call — and writes
.metaproject/data/stack/stack.json.
| Subcommand | Flags | Notes |
|---|---|---|
detect (only subcommand) |
--cwd <dir>, --json, --no-write |
Detects the repository's stack tags. Writes .metaproject/data/stack/stack.json by default; --no-write detects and prints without writing. --json prints exactly the persisted document. --cwd points detection at another directory instead of the current one. |
Every failure mode (an unreadable/unparseable manifest, a workspace root with
no leaf dependency, a bounded scan that hits its file cap) marks uncertain:
true for only the tags that signal's family covers — never every tag —
matching src/review/stack.ts's existing fail-open discipline, generalized.
Re-running detect on an unchanged tree writes a byte-identical file: the
persisted detectedAt is kept whenever the content fingerprint
(inputsSha256) is unchanged.
metrics¶
Provenance-aware execution observability: per-run evidence, active-time accounting, and baseline-aware comparison.
keryx metrics status
keryx metrics collect --events <events.json> [--run-id <id>] [--skill <name>]
keryx metrics validate <run.json>
keryx metrics latest
keryx metrics show <run-id>
keryx metrics compare <run-a> <run-b> [--json]
keryx metrics rebuild --source <events.json>
keryx metrics plan --profile lightweight [--changed <file,...>]
keryx metrics benchmark init --tasks <task-a,task-b,task-c> --out <manifest.json>
keryx metrics benchmark validate <manifest.json>
| Subcommand | Description |
|---|---|
status |
Whether metrics collection is configured, and what has been recorded. |
collect |
Fold an events file into a durable run record. |
validate <run.json> |
Check a run record against the schema. |
latest / show <run-id> |
Print the most recent run, or a named one. |
compare <run-a> <run-b> |
Diff two runs. --json for the machine-readable form. |
rebuild --source |
Regenerate run records from an events file. |
plan --profile lightweight |
Plan a low-overhead collection profile, optionally scoped with --changed. |
benchmark init \| validate |
Create and check a paired-comparison manifest. |
No performance claim has been made about keryx. The benchmark harness exists so that a paired Keryx/no-Keryx comparison can be run and reported honestly, not to support a number that has already been published.
memory¶
Long-term, typed project memory: durable Markdown entries (lessons, decisions,
constraints, known mistakes, patterns, …) under .metaproject/memory/, with
deterministic (non-LLM) search, dedup, and consolidation.
keryx memory new <type> [slug] --title "<title>" [--force]
keryx memory index [--embeddings]
keryx memory search "<query>" [--module <m>] [--entity <e>] [--status <s>] [--limit <n>] [--as-of <YYYY-MM-DD>] [--class <semantic|episodic|procedural>] [--semantic] [--save-report]
keryx memory transition <path> --to <draft|accepted|conflict|deprecated> [--reason <text>]
keryx memory supersede <old-path> --by <new-path> [--date <YYYY-MM-DD>]
keryx memory assets list | verify [<id>] | pull <id>
keryx memory ingest --from-<source> <path>
keryx memory check
keryx memory reflect [--narrate] [--provider <p>]
keryx memory handoff --from <harness> --target <harness> [--scope project|user] [--json]
| Subcommand | Flags / args | Description |
|---|---|---|
new <type> [slug] |
--title "<t>", --force |
Scaffold a new draft entry; print possible duplicates. |
index |
--embeddings |
Build an optional disposable catalog at data/memory/index/index.json; --embeddings additionally builds a disposable vector cache when the capability is available. Search scans canonical Markdown directly and does not consume either generated output. |
search "<query>" |
--module <m>, --entity <e>, --status <s> (e.g. accepted), --limit <n> (1–100), --as-of <YYYY-MM-DD>, --class <semantic\|episodic\|procedural>, --semantic, --save-report |
Filesystem-pure ranked retrieval by default; validates status/class/date/limit before reading. Lexical mode applies a small English suffix stemmer (flow 353, AC5) to both the query and each entry's title/body, so release also matches released/releases/releasing — a separate, narrower normalisation from the base tokenizer dedup/relevant-recall use, so title/summary similarity is unaffected. On zero hits (and no explicit --semantic), the output gains one hint line: --semantic is available when an embeddings index already exists for the project, otherwise how to build one (keryx memory index --embeddings) — as a semanticHint string in --json. --save-report explicitly publishes one bounded immutable report under ignored .metaproject/runtime/memory/search/<run-id>/; without it neither text nor --json writes artifacts. |
Generated memory catalogs and embedding caches under .metaproject/data/memory/
are disposable and ignored. Existing legacy data/memory/artifacts/latest.*
files receive an advisory during init/update; migration is maintainer-owned and
never deletes files or changes the Git index automatically.
| transition <path> | --to <draft\|accepted\|conflict\|deprecated>, --reason <text> | Explicit validated lifecycle transition through the guarded atomic write seam; invalid or terminal edges fail without changing bytes. |
| supersede <old-path> | --by <new-path> (required), --date <YYYY-MM-DD> | Mark one entry as superseded by another. Non-destructive and git-diffable — both entries stay on disk. A blocking security gate can abort the write. |
| assets list \| verify [<id>] \| pull <id> | — | Manage declared assets from assets.lock.json (list/verify/pull; pull is the only networked verb). |
| ingest | --from-review\|--from-health\|--from-job\|--from-skill-verifier <path> | Extract candidate insights from a source artifact into ADD/UPDATE entries. |
| check | — | Integrity/lint pass (metadata, links, dedup, conflicts, index). Exit 1 on issues. |
| reflect | --narrate, --provider <p> | Cluster entries by tag and create pattern drafts for clusters ≥ min size. --narrate adds a model-written summary of the memory and needs a credential — without one it exits 1. |
| handoff | --from <harness>, --target <harness> (both required), --scope project\|user (default project), --json | Explicit cross-harness memory read: entries whose Source-Harness matches --from and whose Target-Harnesses is unset or includes --target. Fails closed on an unreadable folder, unreadable file, or malformed entry — the result reports status: "incomplete" (exit 1) rather than silently returning a partial set as if it were complete; a clean scan reports status: "complete" (exit 0). An unknown harness id or scope exits 2 with a named reason. |
Entry types: lesson, decision, constraint, known-mistake,
historical-context, pattern, task-note, review-note, incident,
migration-note, integration-note.
When the security module is enabled, ingest runs an advisory security check
before writing each accepted entry. Advisory (the default) reports and writes;
enforced/ci/gateway mode can skip an entry's write with a masked reason.
flow¶
Agent-first work lifecycle ("Task Manager"; manifest module id tasks). Each unit
of work is a self-contained package under .metaproject/flows/, driven through a
strict status state machine with hard completion gates. The CLI is the sole writer
of flow state.
keryx flow init (--issue <url> | --title "<t>") [--slug <s>] [--base <branch>] [--owner "<name>"] [--outcome-author agent|human] [--origin <kind> --quote "<verbatim>" --source "<ref>"]
keryx flow list
keryx flow status <id>
keryx flow freeze <id>
keryx flow plan <id> [--provider <p>] [--json]
keryx flow start <id>
keryx flow next <id> [--json]
keryx flow task add <id> --title "<t>" [--kind <k>] [--depends T1,T2]
keryx flow task done <id> <taskId> [--disposition <d>] [--reason "<why>"]
keryx flow task attempt <id> <taskId> --outcome started|failed|blocked [--detail "<what>"]
keryx flow task depends <id> <taskId> --on T1,T2|none --reason "<why>"
keryx flow owner set <id> --owner "<name>" --reason "<why>"
keryx flow outcome author <id> agent|human --reason "<why>"
keryx flow origin set <id> <kind> --reason "<why>" [--quote "<verbatim>"] [--source "<ref>"]
keryx flow ac confirm <id> <ACn> [--note "<evidence>"] [--signed-by "<name>"]
keryx flow ac update <id> --reason "<why>"
keryx flow ac update <id> --criterion ACn --text "<criterion>" --reason "<why>"
keryx flow ac reseal <id> --reason "<why>"
keryx flow ac kinds <id> [--json]
keryx flow check-ac <id> [--diff <ref>|--pr <n>] [--json] [--refresh]
keryx flow implemented <id> --pr <url>
keryx flow complete <id> [--comment] [--merged <commit>] [--signed-by "<name>"] [--confirm-token <token>]
keryx flow confirm <id> [--merged]
keryx flow recover <id> --reason "<why>"
keryx flow block <id> --reason "<why>"
keryx flow unblock <id>
keryx flow check
keryx flow renumber <dir> --to <id> --reason "<why>"
keryx flow repair-reviews
keryx flow schema [--out <path>]
| Subcommand | Flags / args | Description |
|---|---|---|
init |
--issue <url> | --title "<t>", --slug <s>, --base <branch>, --owner "<name>", --outcome-author agent|human, --origin <kind>, --quote "<verbatim>", --source "<ref>" |
Scaffold a flow package. Requires a title or issue URL. The new id skips every number a known remote branch already uses, and the package opts into the folder-committed gate (gates.folderCommitted). Writes four default tasks (T1 context, T2 implement, T3 test, T4 review), each marked origin: "scaffold" — see the default task scaffold. --owner names the human accountable for the flow (see the owner and completion signatures) — never inferred, so an omitted --owner leaves the flow with no owner rather than a guessed one. --outcome-author records who wrote the outcome criterion: agent when omitted, human only when the flag says so, never inferred; any other value is refused before the flow is created. It gates nothing (see the outcome author). --origin records where the flow came from, with the evidence its kind needs; without it, or without the evidence, the flow is created with an unknown origin and the command says why (see the origin). |
origin set <id> <kind> |
--reason "<why>", --quote "<verbatim>", --source "<ref>" |
Change where the flow came from: human-request, agent-finding, agent-proposal, or unknown to clear it. Refuses a missing --reason and any other kind. The evidence rule applies, using the quote and source already on the flow or the ones given here; without it nothing is written and the reason is printed. Writes the origin field and appends one journal.md line naming the old origin, the new one and the reason, together; setting what the flow already holds writes nothing. Works on any flow, never touches outcomeAuthor, gates nothing. |
outcome author <id> agent\|human |
--reason "<why>" |
Change who wrote the flow's outcome criterion. Refuses a missing or empty --reason and any other value. Writes the outcomeAuthor field and appends one journal.md line naming the old value (or unknown), the new value and the reason, together; setting the value already held writes nothing. Gates nothing. |
list |
— | List all flows with status + task counts. |
status <id> |
— | Print one flow: status, source, AC state, PR, outcome author (agent, human or unknown), origin (kind, quote, source; origin: unknown for a flow without one), owner, latest signature, tasks, recent history. For a done flow whose directory git tracks, it ends with a note: when that directory has uncommitted changes (a common cause is the closing state flow complete writes after the merge; informational; nothing gates on it, and a flow directory git does not track gets no note). The TUI's /flows detail tab shows the same note. |
freeze <id> |
--yes (or env KERYX_NONINTERACTIVE=1) |
Record the AC checksum; transition initializing → ready. Also derives each criterion's verification kind from its trailing marker (see verification kinds) into acKinds and prints the distribution. A kind never refuses a freeze; a malformed marker is printed as a warning and reads unclassified. Criteria with no marker print warning: no verification kind on AC1, AC2 — …; on a terminal (not under NODE_ENV=test or CI) the command then asks freeze without a verification kind on …? [Y/n]; an empty answer or yes freezes, and only an explicit no cancels (the flow is left unchanged). --yes or KERYX_NONINTERACTIVE=1 skips the prompt. A non-interactive run always freezes. The warning and prompt appear only when the freeze can happen (a flow in the right status with real criteria). Either way that freezes appends one journal.md line (the warning, or "operator froze without a verification kind on …"). |
plan <id> |
--provider <p>, --json |
Needs a model credential. Break the flow's frozen acceptance criteria into a proposed task breakdown. Exits 1 without a credential. |
start <id> |
— | Transition ready → in-progress. |
next <id> |
--json |
The first task that is not done and whose declared dependsOn are all done — the resume decision, computed from the record rather than re-derived from prose. Exits 1 when work remains and nothing can start (an unsatisfiable dependency or a cycle); keryx flow check names which. Also reports the task's resume state — never-started, ended (a prior attempt reported how it finished), or unresolved (an attempt was opened and no end was recorded, so whether its work landed cannot be told from the record) — plus every other not-done task carrying an unresolved attempt. --json carries this as resume and unresolved. |
task add <id> |
--title "<t>" (required), --kind context\|implement\|test\|review\|docs, --depends T1,T2 |
Append a task. --depends is read by flow next and validated by flow check. |
task done <id> <taskId> |
--disposition completed\|blocked\|failed\|skipped, --reason "<why>" |
Mark a task done. A failed or blocked close records an attempt automatically — those two dispositions are attempts by definition, and requiring a second command is why the counter read zero across seven flows. |
task attempt <id> <taskId> |
--outcome started\|failed\|blocked (required), --detail "<what happened>" |
Record one execution attempt explicitly. Appends to the same append-only log task done writes to. |
task depends <id> <taskId> |
--on T1,T2\|none (required), --reason "<why>" (required) |
Rewrite one task's dependsOn — the repair for the three unsatisfiable shapes flow check reports. Until this existed, --depends wrote the field once at creation and nothing could rewrite it, so the only remedy was editing flow.json by hand; flow 178 carried a self-dependent T10 for two weeks because the check was right and the operator had nowhere to go. Ids are normalised, so t1, T1 and t1 are one edge. The change is validated against the same dependencyIssues the check uses and refused if it would introduce an issue that was not already there — deliberately narrower than "the graph must end clean", because a flow with two broken tasks has to be repairable one task at a time. A refused change leaves the record exactly as it was found. --on none clears the field. |
owner set <id> |
--owner "<name>" (required), --reason "<why>" (required) |
Set or change the flow's owner — the human accountable for it. Never inferred, and a --reason is required even for the first assignment. Every change is kept, not overwritten: it appends a history event naming the previous owner, the new owner, the reason and the time, so no earlier owner is ever lost. See the owner and completion signatures. |
ac confirm <id> <ACn> |
--note "<evidence>", --signed-by "<name>" |
Confirm one acceptance criterion. Warns, and still confirms, when the criterion carries a sensitivity note and --note has no evidence: <ref> label (case-insensitive, with a word boundary before it, so no-evidence: does not count; the reference is the rest of the note up to the next ;, trimmed, and must be non-empty and not n/a, na, none, -, tbd, todo or ref; leading and trailing punctuation, symbols and whitespace are ignored, so n/a. and (none) do not count, and the <ref> template pasted from the warning does not count either; nothing else in the note is inferred as evidence). Appends an append-only signature recording who confirmed it (see the owner and completion signatures) — a repeated confirmation of the same criterion adds a new signature rather than replacing the last one. |
ac update <id> |
--reason "<why>" (required); or --criterion ACn --text "<criterion>" together with --reason |
Re-freeze the AC checksum and void every prior confirmation — right when the criteria changed, because a criterion nobody confirmed in its current wording has not been confirmed. Wrong when only the seal is stale; use ac reseal for that. Without --criterion/--text, re-freezes the file exactly as an operator already edited it (the only behaviour before flow 293). With both, rewrites that one ACn line's text itself — only when the criterion is genuinely a single line, the format the file's own Rules section prescribes — or appends it, when ACn is the next unused number (highest existing ACn + 1; a gap in the numbering, e.g. AC1/AC2/AC4 with AC3 missing, can never be filled this way — the refusal names the gap and says to edit the file directly and re-freeze with --reason alone). If the target criterion has ANY indented, non-blank line following it before the next - ACn: line, a blank line, or a heading — a criterion wrapped across two lines, a sub-bullet evidence note, a fenced code block, anything — the command refuses, naming the criterion, and writes nothing; there is no way to tell "this is the criterion continuing" from "this is unrelated content under it" from the file alone, so it never guesses at either. Edit the file directly and re-freeze with --reason alone instead. --text and --reason must each be non-empty, fit on one line, and (for --text) not repeat its own - ACn: prefix. --criterion and --text must be given together; either alone is refused. The flow's history records the criterion, its (single-line) previous text and its new text alongside the reason. The write itself preserves the rest of the file's bytes exactly: an untouched line keeps its own original line ending (even in a file with mixed \n/\r\n endings), and an appended line takes the ending of the line it follows. |
ac reseal <id> |
--reason "<why>" (required, one line) |
Re-seal a stale checksum over a file that did not change, keeping the confirmations. Refuses unless git reports the criteria file tracked and unchanged against HEAD, and refuses when git cannot answer at all — no evidence must not read the same as clean. It proves the file being sealed now is the file committed now; it cannot prove the old checksum was ever right. Exists because the only other repair destroys the record: flow 002 carries ten dated confirmations against a criteria file byte-identical to its first commit, with a checksum sealed against content predating the squashed 0.1.0 import. |
ac kinds <id> |
--json |
Report each criterion's verification kind, the distribution, the runnable coverage and how many exec/invariant criteria carry a sensitivity note, read from the criteria file (see verification kinds). Read-only, no model call; exits 1 when a marker is malformed, naming the criterion. |
Every ac subcommand refuses an argument it does not use — an extra positional, an unrecognised flag, or --text without --criterion (or the reverse) — rather than dropping it silently and reporting success. ac update <id> AC1 --text "…" --reason "…" (the syntax before flow 293) is refused: AC1 is not a positional ac update accepts. Every value flag (--note, --signed-by, --reason, --criterion, --text) consumes the very next token as its value even when that value itself starts with -- (e.g. --note "--dry-run mode was used"), unless that next token is itself one of the subcommand's own flag names — then it is refused as a missing value (missing value for --note) rather than silently swallowing the next flag as text.
| check-ac <id> | --diff <ref>, --pr <n>, --json, --refresh | ADVISORY. Jev checks the flow's change against its FROZEN acceptance criteria; never changes flow state and never confirms an AC. --refresh bypasses a cached result even when the diff and criteria checksum match. See check-ac below. |
| implemented <id> | --pr <url> (required) | Transition in-progress → implemented; record the draft PR. |
| complete <id> | --comment, --merged <commit>, --signed-by "<name>", --confirm-token <token> | Run completion gates; on pass → done (optionally comment the issue) and append a completion signature, on fail → in-progress. After a successful completion it prints the same note: as flow status when the git-tracked flow directory has uncommitted changes (the closing state written after the merge is a common cause); the note never changes the exit code. Every outcome but a dead process leaves the flow in in-progress or done, never in completing: a gate that throws, or a criteria file changed mid-run, is recorded as a failed attempt. See the owner gate, the folder-committed gate, the owner and completion signatures and the confirmation token. |
| check-complete <id> | --merged <commit>, --confirm-token <token>, --json | Evaluate every gate complete would, through the same function, and write nothing: no status change, no completionAttempts entry, no signature, no lock, no spent token. Also reports whether complete could start from the flow's status, and the PR's merge state from the tracker: merged, open, closed, not-found, no-pr, or unknown whenever the tracker did not say (with --merged, merged means the main-merge gate passed). Under each failing gate it names the command that fixes it where one is known, such as keryx flow ac confirm <id> AC2. The merge state is reported beside the gates, not as one: the pull-request gate asks for green checks. Exits 0 when complete would pass, 1 when it would not, and 2 when the check could not run (an unknown id, say); with --json that case prints {"error":{"message":...}}. The /governance modal's c runs the same check. |
| confirm <id> | --merged | Mint a completion confirmation token for a flow that requires one. Refuses unless stdin and stdout are terminals, the flow is implemented (or in-progress with --merged), and its criteria are frozen and unchanged. Shows what is being confirmed, asks for a random code typed back on /dev/tty, then prints the token once. See the confirmation token. With --merged the token binds only merged, not a commit: the commit is named later, at flow complete --merged <commit>, so the token does not pin which commit that is. --merged is accepted on an implemented flow that records a PR too, matching flow complete --merged being allowed from implemented; the token then binds merged, and a PR completion with it fails as token_target_mismatch. |
| recover <id> | --reason "<why>" (required) | Move a flow left in completing (by a process that died mid-complete) back to in-progress, recording the reason, the last event before the interruption, and whether the criteria file is intact. Refuses from any other status and while another process holds the flow's lock. flow status and the TUI's /flows view label such a flow interrupted and name this command. |
| block <id> | --reason "<why>" (required) | Transition any status → blocked, saving the previous status. |
| unblock <id> | — | Restore the saved previous status. |
| check | — | Consistency audit across all flows: structure, checksums, schema, duplicate ids, plus every dependsOn that can never be satisfied (unknown id, self-reference, cycle) and every task recorded failed/blocked with no attempt behind it. A local folder whose number the default branch (<remote>/main, <remote>/master or the <remote>/HEAD target) holds under a different folder name fails as duplicate-id; a clash with any other remote branch is only a warning (that branch may be stale), and flow init still skips its number. The failure message names the ref and ends with keryx flow renumber <dir> --to <free id> --reason "<why>". A flow folder that is not committed (not in HEAD) is a warning, never a failure: after the normal output it prints flow folder <dir> is not committed: commit it in the same PR as the code, and the exit code is unchanged. Both are silent outside a git repository. flow list and the TUI (/governance flow list and detail) tag the same flows dup id / not committed. |
| renumber <dir> | --to <id> (required), --reason "<why>" (required) | Repair a duplicate flow id. Refuses an id a known remote branch uses (Flow id <id> is already used on <ref> (<dir>) and cannot be reused). |
| repair-reviews | — | Re-point review records (manifest.json, scope.md, findings.json, review-note links) of flows renumbered before renumber rewrote them, by replaying id-map.json against each flow's current directory. Idempotent. |
| schema | --out <path> | Emit the flow JSON schema. |
Statuses: initializing, ready, in-progress, implemented, completing,
done, blocked. task and ac are command groups — the atomic verbs are
task add, task done, task attempt, ac confirm, ac update.
When the security module is enabled, complete adds a security completion
gate. Advisory (the default) makes it informational (pass, never blocks);
enforced/ci/gateway mode can fail the gate and hold the flow in
in-progress. The gate is omitted entirely when the module is disabled.
Verification kinds¶
A criterion may end with one trailing marker that says how it is verified. The marker is part of the criterion line, so the freeze checksum covers it:
- AC1: the parser rejects two markers [verify: exec `bun test src/flow/ac-kinds-errors.test.ts`]
- AC2: no code path refuses a freeze on a kind [verify: invariant `bun test src/flow/ac-kinds-never-gates.test.ts`]
- AC3: the wording is clear to a new reader [verify: judged]
- AC4: the release is announced [verify: none — a person posts it]
| Kind | Meaning |
|---|---|
exec |
a command whose exit code decides the criterion |
invariant |
a command that must keep passing after the work |
judged |
a person reads the evidence |
none |
honestly unverifiable; the reason is required |
unclassified |
no marker — every criterion written before kinds existed |
Rules the parser keeps: a marker inside a code span is prose, not a marker; two
markers outside code are an error; the marker must be the last thing on the line
and close with ]. An unmarked criterion is unclassified, never none — a
missing decision is not a decision that nothing can be checked.
acKinds on flow.json is derived from acceptance-criteria.md at
flow freeze and flow ac update; the file stays the source of truth. A flow
frozen before kinds existed has no acKinds, which reads as fully unclassified
(not as zero criteria). An exec or invariant marker may carry one optional sensitivity note, which
says how the check was shown to catch a regression:
- AC5: a failed save shows an error and a retry creates no duplicate [verify: exec `bun test src/save.test.ts` · sensitive: failure-scenario]
The separator is · or an ASCII ;. The values are revert (the check fails
with the change reverted), mutation (it fails against a mutated build) and
failure-scenario (it exercises the failure path and asserts an observable
result). The note is rejected on judged and none: such a criterion reads as
unclassified with a parser error. flow ac kinds counts the criteria that
carry it, and flow ac confirm warns when the --note has no evidence: <ref> label.
A kind is information only: it never refuses a
freeze, a confirmation or a completion, and it never makes a step mandatory.
flow check-ac strips the marker before it looks at a criterion's wording.
Writing the marker: flow init scaffolds each criterion as
- AC1: <replace …> [verify: <exec|invariant|judged|none>], and the
flow-init and flow-orchestrator skills require a marker on every criterion,
with one question to the operator before the freeze when a kind is unclear.
Flows that /goal --auto provisions carry [verify: judged]. flow freeze
warns about criteria still unclassified (see its entry above); this is the only
place a missing kind is asked about, and the answer gates nothing.
Where it shows up: the distribution block printed by flow freeze;
flow ac kinds <id> [--json] (exit 1 naming the criterion on a malformed
marker); an acceptance coverage line per flow in keryx governance report
(a flow with no acKinds counts as fully unclassified); and the AC tab of the
TUI /flows modal, one row per criterion under the distribution block
(PgUp/PgDn scroll it).
flow check-ac¶
Jev checks a flow's change against its FROZEN acceptance criteria (flow 328). Always advisory: it never changes flow state, never confirms an AC, and its own errors never fail a caller command.
The change defaults to the flow worktree's diff against its base branch (or
origin/main), against their MERGE BASE — same reasoning review floor's
--ref uses. --diff <ref> diffs against another ref's merge base instead;
--pr <n> reads the pull request's diff via gh pr diff <n> (repo inferred
from the checkout's origin remote) instead of local git.
Results are cached per flow, keyed on (criteria checksum, diff hash) — a
repeat call against an unchanged diff and unchanged frozen criteria reads the
cache instead of asking Jev again. --refresh bypasses that cache even when
both match, forcing a fresh Jev call — the same "the cache exists, ask
anyway" shape as keryx providers status --refresh and keryx learn accept
--refresh.
For every criterion in acceptance-criteria.md:
- Deterministic facts first (AC2). Backticked tokens, file paths and
keryx <cmd>names named IN the criterion's own text are checked against the diff and the changed-file list, and any changed TEST file mentioning one is noted — computed with no model call, and placed above the diff in Jev'sstate. not-checkable, without a model (AC3). A criterion about a live check, CI green,keryx health run, docs being published, or a manual/ human-process step is recognised by an explicit, documented marker list and never sent to Jev — always listed, never dropped.- One
noulquestion per remaining criterion (AC4). Batched under the vendor's 64kstate+questionsbudget (packed to half that, with a cap on criteria per batch — a real large diff can make a few generic tokens match far more hunks than the documented ceiling actually affords), with only the diff regions the criterion's own tokens matched — redacted viasrc/security/service.ts— or, when nothing matched, the changed-file list and that fact. Opt-in per project:{"review":{"jev":{"ac_check":true}}}in.metaproject/tasks.config.json. Without the opt-in, or without an OpenRouter credential, the command still prints every criterion's deterministic evidence and says plainly that Jev was not asked — nothing is silently skipped.
Each checkable criterion is reported likely-met or not-evident (Jev's
probability against a 0.5 threshold), alongside its probability, facts and
matched evidence paths.
Cached (AC8). Results are cached per flow under .metaproject/data/ac-check/
(gitignored, mode 0600), keyed by (criteria checksum, diff hash) — an
unchanged diff against unchanged criteria reads the cache rather than asking
Jev again.
Advisory notices (AC5). flow implemented and flow complete print a
one-line summary (counts, and which criteria are not evident) when the
opt-in is on — never blocking, and a check failure is one line, not a
command failure.
Review attachment (AC6). keryx review ingest for a flow with the
opt-in attaches the latest CACHED result as ac-check.md in the review
package, so reviewers see which criteria are in doubt — this never triggers
a fresh Jev call itself. The cache is attached only when its key (criteria
checksum + diff hash) matches THIS round's own diff (the merge-base of
--ref/the recorded --head, the same shape check-ac itself diffs
against); a cache that exists but does not match gets a short STALE note
instead (naming when it WAS checked, and that --refresh re-checks it),
never a report that reads as current when it is not.
Shell (AC7). /flows' modal carries an "AC" tab with per-criterion
markers (met / not evident / not checkable / not run); c re-runs the check
for the selected flow, and /ac opens the modal straight to that tab.
The default task scaffold¶
flow init writes four tasks into every new package — T1 collect context, T2
implement, T3 test, T4 self-review — each carrying origin: "scaffold" so a
generated row is a recorded fact rather than a title match. They are a default
checklist, not a plan: add your own with flow task add, and close any scaffold
row your plan supersedes with a stated reason:
Measured over the 206 packages that existed when this was last reviewed: no flow
has ever replaced the scaffold (flows extend it), 91.5% of scaffold rows reach
done, and 43% of flows record no task beyond these four — so for nearly half
of all flows this scaffold is the entire task list. Removing it would leave those
flows with an empty task list, and an empty list passes the task gate vacuously
(0 task(s) terminal), which is weaker than the four rows it replaced.
Scaffold rows never expire. Nothing closes them on a timer, at completion or
anywhere else. disposition: "skipped" means somebody judged the work
unnecessary; "nobody looked for N days" is the absence of that judgement, and
recording it as one is the defect the task gate exists to prevent. What the tool
does instead is make the debt visible and cheap to clear: when the task gate
fails, it names the scaffold rows that were never started separately from the
flow's own open work, and prints the command above. The --reason stays yours.
The review completion gate¶
complete also runs a review gate over the flow's managed review packages
(.metaproject/flows/<flow>/reviews/). It passes only when all five of these
hold, and the failure names which one did not:
- A managed review record exists, and every round in it is readable. A round
whose
manifest.jsonorfindings.jsonis missing cannot be cited — nothing durable records what it found — and a round that cannot be read is not a round that found nothing: it fails this condition rather than being skipped over. Whatever of it can be read still counts towards condition 2. - No finding is left without a terminal disposition, at or above the
severity floor. "Terminal" is defined positively, per finding:
acted-onneeds a commit SHA and a verifierrefutedverdict citing that SHA;dismissed-incorrectneeds a verifierrefutedverdict with a method and evidence; the three "correct, but not now" dismissals need a recorded human decision;answered-disagreeneeds a reply (external_ref.reply_url, or one named in the evidence) — refuting somebody else's comment is not answering it. A finding that simply stops appearing in later rounds is not cleared — the check runs over the latest state of every finding ever raised, so absence never reads as a fix. - The latest round ran against the PR head commit. A clean round against a
stale SHA proves nothing about what will merge. The round records this as
manifest.target.head, written byreview start|attach|ingestfromgit rev-parse HEAD(or from--head); a round that recorded none is unobserved, which fails.
On flow complete --merged <sha> there is no PR head, and the merged commit
stands in for it. The test there is containment, not equality: the round
passes when its head is an ancestor of (or the same commit as) the merged
commit, which is what a merge commit gives you. A squash or rebase merge
rewrites the branch commit, so the reviewed SHA can never appear in the merged
history and this condition cannot be satisfied by re-running the round — the
failure says so and names the two things that do work: ingest a round with
--head <merged-sha>, or record the pull request on the flow so the round is
compared against the PR head instead.
4. No external PR comment is unanswered, and the collection that says so is
current. Answered from the durable record keryx review comments
collect|reply writes
(.metaproject/reviews/pr-comments/<owner>__<repo>__<n>.json), not from a
reviewer name in manifest.coverage — that is written straight from
--reviewers and collects nothing. A flow with a PR and no such record is
unobserved: "nobody commented" and "nobody looked" are different facts.
A record that exists is not enough. It carries collected_sha — the head
comments collect --sha read — and the gate compares it with the PR head the
same way it compares the round's. A collection made before the comments
arrived is unobserved, as is a record with no collected_sha at all
(written by a keryx older than the field): a count of rounds is not a date,
and an undated collection cannot be shown to be current.
5. The verifier ran and its stats are on the record (verification_mode in
the round's scope.md). off fails; so does a mode with
claims_received: 0 when the round retained a finding at or above the
severity floor — the mode names an intention, the claim count is what was
actually read, and annotate is the default while --verifications is
optional. A round with no claims_received: line is unobserved. A round
that retained nothing blocking passes on zero claims: there was nothing to
verify.
A condition that could not be observed fails the gate just as a violated one
does — the two are reported differently because the fixes differ, but neither
passes. Reaching the review round cap with the gate unsatisfied leaves the flow
in-progress and reports the blocker; it never force-completes.
Like the tasks gate, review is opt-in per package (gates.review,
written by flow init), so packages created before it existed report skipped
rather than being retroactively invalidated.
Optional per-project configuration, in .metaproject/tasks.config.json:
severity_floor is blocker, major or minor (default minor); info
never blocks and is clamped to minor with a note. require_clean_round: false
turns the gate off, and says so in the gate list rather than disappearing.
The owner gate¶
Like tasks and review, the owner gate is opt-in per package (gates.owner,
written by flow init): every flow created after this landed carries the flag;
flows created before it report the gate skipped and are never retroactively
blocked by a concept they predate.
For an opted-in flow, complete fails the owner gate — with the reason
no owner set; run \keryx flow owner set — whileflow.owneris absent, and passes it once one is set (flow init --ownerorflow owner set`).
The folder-committed gate¶
The flow folder is committed in the same PR as the code, and the commit at
closing is a rule to follow, not one the gate checks. The gate requires one
thing before closing: flow complete fails the folder-committed gate when
.metaproject/flows/<dir>/flow.json is not in HEAD, with the reason
flow folder <dir> is not committed. Commit it (git add .metaproject/flows/<dir> && git commit) in the PR that carries the code, then run flow complete again.
Like tasks, review and owner, the gate is opt-in per package
(gates.folderCommitted, written by flow init): flows created from 0.3.53 on
carry the flag; an older package reports the gate skipped and is never
retroactively blocked. The gate is also skipped outside a git repository and
when git cannot answer. It checks the committed HEAD, not the index, so a
staged-but-uncommitted folder still fails. It sits after the owner gate and
before the review gate in the gate list.
flow init and flow renumber also avoid clashes across branches. Flow ids
come from a clone-local ledger, so a second clone, or a branch you have not
fetched, can hand out a number the first one already used. The ledger now also
reserves every number held by a known remote branch: the remote-tracking
refs (refs/remotes/*) this clone already has, read with git ls-tree and no
network. Up to 500 remote-tracking refs are read (<remote>/main,
<remote>/master and the <remote>/HEAD target first); a ref this clone never
fetched is invisible, and the repair for a clash it causes is flow renumber.
Only folders named NNN-... (exactly three digits, then a dash) count. flow renumber --to <id> refuses an id used on such a branch.
flow check reports a clash that already happened (see below): a failure when the default branch holds the number, a warning for any other remote branch. When the repository
has no remotes, no refs, or is not a git repository, all of this reads as "nothing
known" and changes nothing.
The owner and completion signatures¶
A flow can name an owner — the human accountable for it — and ac confirm/
complete each record a signature: who acted, when, and what exactly was
signed (the AC id and the frozen acceptance-criteria checksum for a confirmation;
that checksum plus, for a completion, the head commit the pull-request gate
observed — read from that gate's own prStatus() call, not re-fetched for
the signature). complete() runs several gates that each read the PR head
independently (the pull-request gate, the base-branch gate, the review gate);
a push landing mid-complete() can make them observe different commits, and
the signature names only the one the pull-request gate saw — not a guarantee
that every gate agreed on the same head. Signatures are append-only:
reconfirming a criterion, or completing a flow more than once, adds a new
signature rather than replacing the last one.
The owner is never inferred. It is set only by an explicit --owner "<name>"
on flow init or flow owner set <id> --owner "<name>" --reason "<why>" — never
read from git, an environment variable, or anywhere else. A flow nobody named an
owner for reports owner: not set in flow status, never a guessed name, and
every change is recorded as history rather than as an overwrite (flow owner set
requires a reason even the first time, and each call appends the previous value,
the new value, the reason and the time).
A signature's signer is a claim, not proof. --signed-by names the signer
explicitly. Missing that, ac confirm/complete fall back to the KERYX_ACTOR
environment variable, then to git config user.email in the checkout, then to
unknown. Every recorded identity carries a basis — stated (an explicit flag
or environment variable), derived (read from something like the local git
configuration — never promoted to stated), or unknown — and a source
naming exactly where it came from. None of --signed-by, KERYX_ACTOR, or a
local git identity is proof a human, rather than an agent running the same
commands, made the assertion; keryx flow complete says so in its own output,
and this reference says so here rather than overclaiming. See
TM-02: Flow Owner and Signed Completion
for the full design, the JSON Schema shape, and why a stronger (structural,
not just claimed) human-presence guarantee was considered and deferred.
Every pre-existing flow.json with no owner or signatures field keeps
loading, validating, passing flow check, and completing exactly as before —
these fields are additive and optional, like every Task Manager v2 field, and
reading an old file never rewrites it on disk.
The outcome author¶
A flow's flow.json can record who wrote its outcome criterion, as outcomeAuthor:
agent or human. It labels a sample and nothing more, so that the product
module's G1a can be read in four cells (human or agent, by a real criterion or
not measured — <reason>) and G1b split by author: agent flows measure compliance
with the instruction to fill the slot, human flows measure acceptance, and
conclusions about acceptance are drawn only from human flows.
It is never inferred. flow init records agent when --outcome-author is
absent and human only when the flag says human, never from a git identity, an
owner or the environment; any other value, and a flag given more than once (in
either spelling), is refused before the flow is created.
keryx flow outcome author <id> agent|human --reason "<why>" changes it: the
reason is required, one journal.md line names the old value (or unknown), the
new value and the reason, and the field and the line are written together;
setting the value the flow already holds writes nothing. A flow created before the
field existed reads unknown in flow status, product open and the TUI, and
reading it never rewrites the file. The flag gates nothing: no completion, freeze,
creation or index result, and no exit code, depends on it. With --outcome-author
human the ## Outcome criteria section of description.md is the template's own
text, with no example inserted.
The origin¶
A flow's flow.json can record where it came from, as origin:
{kind, quote?, source?}. The idea of a flow almost always comes from a human and
is formalized by the agent, so who typed the outcome criterion says little; the
origin records whose idea it was and keeps the human's own words. There are three
kinds:
human-request: a human gave the idea, the agent discussed it, the human confirmed the creation;agent-finding: the agent found it in a check, review or test, and the human confirmed;agent-proposal: the agent proposed a new idea, and the human confirmed.
Evidence, not assertion. flow init --origin human-request --quote "<verbatim>"
--source "<ref>" is recorded only with a verbatim quote of the human's first
message with the idea AND a source (channel, message id or time). The quote is
stored byte for byte: no translation, no paraphrase, Cyrillic, quotes and line
breaks intact. agent-finding and agent-proposal need a source. Without the
evidence, or with a kind that does not exist, the origin stays unknown, the
command still succeeds (exit 0) and prints why. The ## Outcome criteria
section of the new description.md then holds three lines: the request
(Запрос (дословно), or Источник for the two agent kinds), the agent's
formalization (Эффект (формализация агента)) and how to observe it (Как
наблюдать (предложение агента), marked as the agent's proposal).
keryx flow origin set <id> <kind> --reason "<why>" changes it on any flow: the
reason is required, one journal.md line names the old origin, the new one and the
reason, and setting what the flow already holds writes nothing. There is no mass
relabelling, and a flow created before the field existed reads unknown.
outcomeAuthor stays, because G1a is computed on it, but it is secondary: when it
was not set explicitly it is derived from the origin (agent: the agent is the one
who types the criterion, whoever had the idea), and an explicit setting is never
overwritten. flow status, product open, the product index and the TUI flow
inspector show the origin, and the product module reads G1a by origin and by
real criterion | not measured, with a flow that has no origin counted as
unknown. The origin gates nothing: flow init, freeze, complete and the
product commands succeed on a flow with no origin, an unknown one or an invalid
kind (which flow check reports as a schema problem and every reader reads as
unknown). In the shell, /flow origin <id> shows it and
/flow origin <id> <kind> --reason "<why>" sets it.
The confirmation token¶
A flow can require a confirmation token before it completes. It opts in when
it is created, with flow init --require-confirmation, or with
completion.require_confirmation: true in .metaproject/tasks.config.json.
flow init stamps the answer into the new flow as gates.confirmation, so
changing the config later never alters an existing flow. Every other flow, and
every flow created before this existed, reports the confirmation gate as
skipped.
keryx flow confirm <id> # in a terminal: review, type the code back, get a token
keryx flow complete <id> --confirm-token <token> # the confirmation gate passes; the token is spent
flow confirm refuses to mint unless all of these hold:
- stdin and stdout are both terminals;
- the flow is implemented (or in-progress, with --merged);
- the criteria are frozen and unchanged;
- a random code is typed back on /dev/tty, so a pipe on stdin cannot answer.
It stores only the token's sha256, in confirm-token.json inside the flow
directory, bound to the flow, the completion, the criteria checksum, and the
target you were shown: the PR URL, or merged for --merged. A token
minted while looking at one PR fails as token_target_mismatch if the flow is
later pointed at another PR, or completed with --merged instead (and the
reverse). Only
the latest mint is valid. A token lives for ten minutes. flow complete checks
it when the attempt starts, so a slow health gate cannot expire it midway. It is
spent only on a passing completion, so a failed attempt can be fixed and retried
with the same token. An expired, spent, other-flow, superseded or
pre-criteria-change token each fails the gate with its own named reason.
The completion signature keeps its identity exactly as before and gains a
confirmation field: mechanism: "terminal-token", a tokenRef (a prefix of
the stored hash, never the token), mintedAt, consumedAt and boundTo.
flow status, flow complete and keryx governance report show it.
What the token does not prove, stated rather than implied:
- What it does prove. A step that no agent tool can perform ran within the TTL, for exactly this criteria checksum. Not that a human ran it, and not who.
- The command-text floors can be evaded. keryx's own agent loop, its ACP
classifier and its supervised-codex path ask a human before running any
command that contains the words
flow confirm, in every permission mode. No session pattern or remembered grant answers that prompt, and "always" is never offered for it. Unattended trigger runs refuse it outright. But these checks match text: a variable, a script file orbun -espells the same thing without the words. - A pseudo-terminal can be faked.
script -qc '…' /dev/nullgives any command a terminal with no person present. So an agent with an unrestricted shell can runflow confirmand type the code itself. - The hash store can be forged. It is a plain file. An agent that can write files can write the hash of a token it chose. The harness and ACP refuse to edit it, but a shell redirect is not path-classified.
- Agents outside keryx supervision are not gated at all. An editor's own shell tool, for example, never passes through keryx's approval prompt.
- The opt-in lives in
flow.json. A shell-capable agent can hand-editgates.confirmationaway. - Handing the token to an agent delegates the completion. That is the intended handoff, and all the record can show is that someone completed the interactive step.
The same limits apply to SAC's keryx workspace confirm-review token. See
TM-03: Terminal Confirmation Token.
Completion attempts (gate outcomes)¶
Every flow complete invocation — pass or fail — appends one entry to
FlowState.completionAttempts: the outcome (pass, fail, or skipped) and
detail of every gate that attempt evaluated, whether the attempt passed
overall, and the acceptance-criteria checksum in force at the time. Unlike
gates.owner/gates.review/gates.tasks/gates.folderCommitted, this is not opt-in — it is written
on every attempt from every flow, starting the moment this field shipped — and
it does not bump schemaVersion. A flow.json written before this field
existed simply has no completionAttempts; keryx governance report reads
that absence as gate outcomes: not recorded, never as "every gate passed".
The record is written before the attempt's final state transition, not
after — specifically so that an acceptance-criteria file edited out-of-band
while a later gate (health, review, security, …) is still running cannot cost
the attempt its record. If that race is caught, the attempt is persisted as
failed, with an extra acceptance-criteria gate entry naming the tamper,
alongside whatever the earlier gates already decided — not silently dropped
by the exception the stale criteria file still throws a moment later.
Growth is unbounded, on purpose — the same choice signatures already
makes. Every entry costs one flow complete invocation, made by a human or
an agent that decided to attempt completion; nothing amplifies it (a single
gate re-run inside one attempt is not a second entry). A flow that has been
completed and reopened repeatedly might carry a few dozen attempts over its
whole lifetime — nowhere near the volume that would make truncation worth the
honesty cost of a record captioned "the last N attempts" instead of "every
attempt". If a pathological retry loop ever makes this a real concern, the fix
belongs beside flow.json's general size (which every field here already
affects), not as a special case for this one array.
rules¶
Keep the root agent entrypoints (AGENTS.md, CLAUDE.md) in sync with the
.metaproject/ workspace by importing them as high-priority project rules and
writing a managed routing block where agentEntrypoints.root puts it —
CLAUDE.local.md and AGENTS.override.md by default, the team files under
scope: "shared" (see
where the block goes).
Requires an initialized workspace.
| Subcommand | Description |
|---|---|
sync |
Import each root entrypoint into .metaproject/rules/<slug>.md, write/upgrade the managed routing block in its targets (regenerating AGENTS.override.md, moving an uncommitted block out of a team file), and refresh the index. |
distill |
Superset of sync: additionally split large entrypoints into typed artifacts (project rules, project skills, root-only sections) and rewrite the trimmed root file. |
Only sync and distill are accepted; the only recognized flag is --help/-h.
An unknown subcommand prints an error and exits 1.
Model choice¶
keryx init, keryx update, and keryx rules sync all render one more policy
line into the managed <!-- keryx:index --> block: Model choice, guiding
Claude Code and Codex CLI toward which model tier to run interactive work, a
subagent dispatch, or a scheduled/unattended run on. It is generated from
keryx's own model tiers (light/standard/deep,
src/gdskills/model-tier.ts) and this project's routing table
(routing.config.json, keryx routing):
- the flagship tier (deep) for planning and review;
- one tier down (standard) for subagents, docs, and unattended work;
- the smallest tier (light) only for trivial, mechanical work.
The text always states tiers by word — never a hard-coded model id. A concrete
provider/model id is added for a category only when this project's
routing.config.json resolves one for it and the operator has approved
that file's current content (keryx routing trust); an unset, unapproved, or
absent routing table leaves every category on tier words alone. Codex CLI's
entrypoint (AGENTS.override.md, or AGENTS.md under shared scope) and Claude
Code's (CLAUDE.local.md, or CLAUDE.md) carry the same block — nothing is
written outside the project (no ~/.codex/config.toml edit).
How a tier becomes a model¶
Tiers are anchored on the session model: standard is the session model, deep
is a larger candidate above it, and light is the next size step below it. Size
comes from the size words in the discovered ids, and version is a second axis:
within one family and vendor a newer generation outranks an older one, and an
older-generation model that is also pricier than the session model is never chosen
for deep. The resolution source is recorded on every dispatch as
tier_resolution:
| Value | Meaning |
|---|---|
discovered |
Ranking worked and a model other than the session's was assigned. |
session-ranked |
Ranking worked and the tier is the session model itself. |
session-fallback |
Ranking was refused or ambiguous; the session model is kept, and tier_reasons says why. |
agent-ranked |
The deterministic ranking could not settle the tier and the fallback agent ordered the candidates. |
tier_reasons ends with a resolve:<reason> entry naming the rule that resolved
the tier (for example resolve:kept-older-generation-pricier).
The agent fallback runs only for a tier the deterministic ranking cannot settle.
It compares candidate models; it never rates how hard a task is. It is shown
discovered model ids and the prices from model-profiles.json (a missing price is
left out, never sent as zero) and must answer JSON. Ids it was not shown are
dropped, the session model must be placed in the order, and it can never put
standard or deep below the session model. A failure, timeout (20 s) or malformed
answer yields the session model. It runs on the light tier of the session's own
provider, so no other provider is called. A validated order is cached in
tier-rank-cache.json next to model-profiles.json, keyed by a hash of the
catalogue; an unchanged catalogue makes zero further calls, and a failure is
remembered in memory for five minutes rather than on disk.
keryx review tier prints tier_resolution (including agent-ranked) and pins a
model id only when the agent picked one other than the session's; the interactive
shell's spawn_subagent records the same source and reason in its run-trace row.
Disable it per project with .metaproject/tasks.config.json:
keryx rules sync and keryx update each print a model_choice: … status
line after regenerating the block, reporting whether it is enabled and how
many of the addressed routing categories resolved to a concrete id.
job¶
Agent-first job packages: the durable state a job-orchestrator run writes as
it moves through its steps, so a job survives a process restart and a reader can
tell what stage it reached without re-running anything. Distinct from flow —
a flow is the managed work lifecycle with frozen acceptance criteria and
completion gates; a job is the orchestrator's own step/document record.
keryx job init --name <slug> [--intent implement|analyze|review|custom] [--project <path>]
keryx job list [--json]
keryx job status <name> [--json]
keryx job step <name> <step-id> --status pending|in-progress|completed|skipped|failed [--reason "<text>"]
keryx job document <name> --type analysis|implementation-report|review|verification-report --file <path>
keryx job complete <name>
| Subcommand | Flags / args | Description |
|---|---|---|
init |
--name <slug>, --intent implement\|analyze\|review\|custom, --project <path> |
Scaffold a job package. --project targets a root other than the cwd. |
list |
--json |
List job packages in the project. |
status |
<name>, --json |
Print one job's state: its steps, their statuses, and the documents attached. |
step |
<name> <step-id>, --status pending\|in-progress\|completed\|skipped\|failed, --reason "<text>" |
Move one step. --reason records why for the non-completed dispositions, so a skipped or failed step carries its own explanation rather than leaving a reader to infer one. |
document |
<name>, --type analysis\|implementation-report\|review\|verification-report, --file <path> |
Attach a typed document to the job. |
complete |
<name> |
Close the job package. |
sandbox¶
Report OS sandbox launcher availability and the per-capability containment
matrix. A report, not a gate — it never runs a contained command and always
exits 0. The containment it describes is enforced by
harness exec, which is where a missing launcher actually refuses.
For the current platform it states whether the launcher (bubblewrap on Linux,
Seatbelt on macOS) is installed, and then, for each capability — filesystem
containment, network-off, domain allowlist, credential masking — whether it is
available, blocked only on the missing launcher, or not implemented on this
platform at all. Those last two are different findings and are deliberately
never worded the same way: "install bubblewrap and this works" and "this does
not exist on Linux" lead to different actions, and collapsing them into one
message is how an operator ends up installing something that was never going to
help. keryx init prints the same matrix once, up front.
standard¶
Validate the workspace against the built-in Metaproject Standard
v0.1 and report its declared capabilities. The checks and schemas are bundled
into the CLI (src/standard/), so no network or docs/ access is needed at
runtime.
keryx standard validate
keryx standard doctor
keryx standard capabilities
keryx standard emit llms [--stdout]
| Subcommand | Description |
|---|---|
validate |
Check required files/dirs, the metaproject.json schema (metaproject.schema.json + per-module module.schema.json), declared paths.*, enabled-module manifests, and that root AGENTS.md/CLAUDE.md link .metaproject/index.md. Prints a PASS/FAIL report and exits 1 on failure. |
doctor |
Same findings as validate, rendered as actionable diagnostics with a concrete fix hint per issue. Exits 1 when unresolved issues remain. |
capabilities |
Print the standard version, declared and satisfied profiles, and each enabled module with its commands/capabilities, sourced from metaproject.json. Exits 0. |
emit llms |
Generate a deterministic llms.txt from the manifest + artifact index. Writes the file by default (validating the result), or streams it to stdout with --stdout. Exits 1 if the generated file is not valid llms.txt. |
validate and doctor also emit profile warnings when the manifest's declared
profiles array drifts from the profiles the workspace actually satisfies
(minimal, agent, ci, full). keryx init and keryx update
keep standardVersion, profiles, and updatedAt current in the manifest, so a
freshly generated workspace validates cleanly.
The only recognized flag is --help/-h. An unknown subcommand prints an error
and exits 1.
agents¶
Four unrelated surfaces share this noun: bootstrap manages global instruction
files for coding agents, external inspects the vendor CLIs keryx can host as
child agents, monitor reads a recorded subagent-fleet event log, and
list/show/export/verify (flow 310) work the agent-definition catalog
described below under "agents catalog".
(It said "two" until 2026-09-05, while the router dispatched three, then
"three" while it dispatched four — each time the sentence counting the
surfaces was itself the thing that had drifted, which is why the coverage
test at src/cli-reference-coverage.test.ts now derives this from the router
instead of trusting the prose.)
bootstrap is not project initialization: it only writes a small managed block
into the selected global AGENTS.md / CLAUDE.md file. The block tells agents to
look for .metaproject/index.md in the current directory or ancestors and route
through Metaproject when present. It also contains an explicit guard: when no
metaproject is installed, ignore the block and continue normally.
keryx agents bootstrap status --runtime <claude|opencode|zcode|codex|antigravity|all>
keryx agents bootstrap install --runtime <claude|opencode|zcode|codex|antigravity|all> [--dry-run]
keryx agents bootstrap uninstall --runtime <claude|opencode|zcode|codex|antigravity|all> [--dry-run]
keryx agents bootstrap print
| Subcommand | Flags / args | Description |
|---|---|---|
bootstrap status |
--runtime <id\|all> |
Show whether each runtime's global instruction file has the current managed Metaproject bootstrap block and print the install command when missing/outdated. |
bootstrap install |
--runtime <id\|all>, --dry-run |
Merge-safe install/update. Inserts the managed block near the top of the global instruction file, after frontmatter and the H1 when present, preserving user content. |
bootstrap uninstall |
--runtime <id\|all>, --dry-run |
Remove only the managed Metaproject bootstrap block, preserving all user-authored content. |
bootstrap print |
— | Print the managed block for manual installation. |
monitor <events-file> |
--json |
Offline fleet report over a recorded subagent event log: what each child was dispatched to do, what it spent, and how it ended. Reads the file and starts nothing. |
Runtime ids: claude (~/.claude/CLAUDE.md), opencode
(~/.config/opencode/AGENTS.md), zcode (~/.zcode/AGENTS.md), codex
(~/.codex/AGENTS.md), antigravity
(~/.config/antigravity/AGENTS.md; alias antigravuty), or all.
agents external¶
Inspect the registry of vendor coding CLIs keryx can host as child agents, or
drive one ACP agent. list and probe are read-only and neither spends
subscription quota: the only process either starts is the registry entry's own
--version. run starts a real agent and spends the operator's quota.
keryx agents external enable [--json]
keryx agents external disable [--json]
keryx agents external list [--json] [--no-probe]
keryx agents external probe <id> [--json]
keryx agents external run <id> --task "<text>" [--unattended] [--write] [--timeout <ms>] [--json]
keryx agents external review <run-id>
keryx agents external apply <run-id> [--allow-flagged]
keryx agents external discard <run-id>
Wherever an agent <id> is taken (and in /delegate, and in a spawn_subagent runtime.agent), the short names claude, codex and agy (also antigravity, any case) work for claude-cli, codex-cli and antigravity-cli. A short name never weakens a per-agent setting: externalAgents.agents["claude-cli"].enabled = false refuses claude too.
| Subcommand | Flags / args | Description |
|---|---|---|
enable |
--json |
Turn the external agent runtime on in one step: externalAgents.enabled in your user config, and, when this project has a .metaproject/metaproject.json, enabled: true on its gdskills.external-agents entry. Changes no other key and no other file; a manifest that is not valid JSON is refused and nothing is written. Idempotent; prints what changed or that nothing did. In the shell: /external-agents on. Model-initiated spawn_subagent calls still ask for approval (externalAgents.spawnDecision). |
disable |
--json |
Reverse exactly what enable wrote. In the shell: /external-agents off. |
list |
--json, --no-probe |
Print every registered agent with its detected availability, transport (line-stream or acp), sandbox modes, and streaming/resume/cost facts, plus the capability gate's verdict. --no-probe skips detection entirely and reports every entry as not probed. |
probe |
<id>, --json |
The same report for one agent id (codex-cli, claude-cli, antigravity-cli, gemini-acp). An unknown id lists the known ones and exits 1. |
run |
<id>, --task, --unattended, --write, --timeout, --json |
Drive one registry agent, either transport, in a disposable git worktree: a line-stream (codec) agent (codex-cli, claude-cli, antigravity-cli) through its own argv/parse/classify codec, or an ACP agent (today gemini-acp, which starts gemini --experimental-acp) with keryx as its ACP client. Guarded by the same externalAgents capability (hard-disabled in CI and under a remote transport) and per-agent config as every external run, plus — for an agent the /external block-list or the one-time-consent set names (today antigravity-cli) — the block-list check and, TTY only, a one-time consent prompt (consent-required without a TTY and no recorded consent). Without a TTY, or with --unattended, every ACP permission that would need a human is refused; a line-stream agent has no permission bridge to refuse into. --write advertises fs.writeTextFile for an ACP agent, or requests worktree-write sandbox for a line-stream one — supported for claude-cli (tools Read Grep Glob Edit Write, no shell) and codex-cli (its OS sandbox: writes confined to the worktree, network closed as far as measured, read-only .git, but a shell that can read any file you can read; needs codex 0.159.2 or newer but older than 0.160.0), with the diff stored for review/apply/discard below, see Let an external agent write; antigravity-cli refuses it with not-implemented (its edit tool writes outside the worktree). For an ACP run, prints the permission mode, whether keryx's MCP server was offered, the permission and fs counts, the patch path and the session id; for a line-stream run, prints the conversation/resume id, cost (if reported) and the count of unrecognised transcript lines. Exits 1 unless the run is Completed. See the ACP client guide and the harness page. |
review |
<run-id> |
Show one stored claude write run: run id, agent, base commit, run status and patch hash, then the flagged paths first (repo plumbing, CI, hooks, agent config), the file list with status (binary files marked), and the redacted patch. Changes nothing. Exits 1 when the run is not found; a run refused at capture (a symlink pointing outside the worktree) is described and can only be discarded. |
apply |
<run-id>, --allow-flagged |
Land a stored write run as the new local branch external/<run-id>: one commit cut from the recorded base commit in a second throwaway worktree, with your own git identity and no trailers. Needs a terminal on stdin and stdout, prints the review, then asks you to type the first 12 characters of the patch hash; anything else cancels. There is no flag or environment variable that answers for you. Refused, landing nothing, when the patch changed since capture, was altered by redaction, carries a binary file, touches flagged paths without --allow-flagged, or the branch already exists. Your current branch, index and working tree are never touched. A run is decided once. |
discard |
<run-id> |
Record a discard decision for a stored write run and delete its patch. A landed or already discarded run cannot be discarded. |
Availability has three states, and the third is not a placeholder:
| Marker | State | What the line says |
|---|---|---|
● |
available | installed, the detected version, how it compares to the recorded range, and "login not verified — keryx cannot know" |
○ |
binary missing | not installed; the binary it looked for is not on PATH |
? |
not probed | not probed, plus the probe command that would answer |
There is no tick and no "ready". keryx never reads a vendor credential store — not even to check whether a login exists — so a found binary proves a binary and nothing more. A version outside the range this build's fixtures were recorded against is a warning, never a refusal.
# agents external
capability: unavailable — the external agent runtime is disabled; set
`externalAgents.enabled` to true in the keryx user config to opt in
● codex-cli Codex
installed, 0.147.0 (within the recorded range); login not verified — keryx cannot know
sandbox: read-only, worktree-write streaming: false resumable: true reports cost: false
The capability: line is the gate's own verdict and always names its reason when
unavailable. The runtime is off by default: it needs
externalAgents.enabled: true in the user config
(~/.local/share/keryx/auth.json), plus keryx init --external-agents when the
cwd is a .metaproject/ workspace. It is hard disabled regardless of
configuration on a remote transport or under CI.
sandbox lists what each CLI itself supports; only
read-only is implemented in this release. See
the harness page
for the runtime this registry feeds.
Exit code is 0 for a successful report — including one where the capability is
disabled or a binary is missing, both of which are answers rather than failures.
agents catalog¶
Flow 310 (W2): list/show/export/verify are a fourth surface under this
noun — the agent-definition catalog (.metaproject/agents/*.md, plus keryx's
own bundled definitions), compiled into either spawn_subagent dispatch
inputs (keryx-shell) or a host's own native/adapter subagent file
(claude/codex/kiro/opencode). See the
agent catalog guide for the definition schema,
export-support levels, the content-hash sentinel export writes, and what
verify's named problem reasons mean.
keryx agents list [--stack <id>] [--json]
keryx agents show <name>
keryx agents export --runtime <claude|codex|kiro|opencode|keryx-shell> <name> [--force] [--dry-run] [--json]
keryx agents verify [<name>] [--json]
keryx agents generate --stack <id> [--check] [--json]
| Subcommand | Flags / args | Description |
|---|---|---|
list |
--stack <id>, --json |
List every catalog definition (bundled and project-local), optionally filtered to one stack pack. Read-only. |
show |
<name> |
Print one definition's fields and its resolved export-support level for every runtime. Read-only. |
export |
--runtime <id>, <name>, --force, --dry-run, --json |
Compile one definition for one runtime and write its managed file (or, for keryx-shell, print the compiled dispatch input — nothing is written for that runtime). A file with no keryx-managed sentinel for this agent at all is never overwritten, even with --force. A managed file whose content-sha256 no longer matches its own recorded hash (hand-edited since export) is refused unless --force is passed. A managed, unedited file from an older source version is updated with no flag needed. |
verify |
[<name>], --json |
Validate every definition (or just <name>) against the schema, tool/skill vocabularies, policy_profile, origin/sourceRef rules, and the baseline-in-body guard, and resolve its export support for every runtime. Also reports stack-pack-not-gate-cleared for a generated pair whose source stack pack is no longer gate-cleared, and generated-drift when a bundled generated file no longer matches a fresh run of the generator. |
generate |
--stack <id>, --check, --json |
Flow 314: write the <stack>-code-auditor (read-only review) and <stack>-build-fixer (build/lint/type/test-failure resolution, worktree-isolated) pair for one stack pack, derived from that pack's pack.json. Refuses a pack that is not gate-cleared (stability: stable and a passing stable-pack gate check). --check reports drift without writing, exiting non-zero if either generated file differs from what is on disk. |
To export the whole catalog for one harness at once, use
keryx integrations install --runtime <id> --surface agents (opt-in — the
default install does not write agent files).
orient¶
Emit or install a compact turn-start orientation block. When the launch cwd has
.metaproject/index.md, the block contains a bounded excerpt of that exact
project-root file, then the current graph map, wiki index, and freshness
information. It does not search ancestors. The excerpt instructs the model to
read the full index; it is precedence guidance, not an enforced runtime gate.
Orientation is separate from the gdctx routing guard: orientation supplies
context, while the routing guard controls which shell/search commands an agent
may run directly.
keryx orient [<runtime>]
keryx orient install-hook [--runtime <id|all>] [--dry-run]
keryx orient uninstall-hook [--runtime <id|all>] [--dry-run]
| Subcommand | Flags / args | Description |
|---|---|---|
| default emit | optional runtime id | Build the bounded project-root Metaproject + graph + wiki orientation and format it for the selected runtime (claude by default). |
install-hook |
--runtime <id\|all>, --dry-run |
Merge-safely install the runtime's turn-start/prompt hook. Supported hook runtimes are claude, codex, and cursor. --dry-run reports the file it would write and changes nothing. |
uninstall-hook |
--runtime <id\|all>, --dry-run |
Remove only the managed orientation integration. --dry-run reports what it would strip and changes nothing. |
Windsurf and Zed do not expose a compatible context-injection hook; use their
rules or memories instead. Unknown runtimes exit 1 without modifying config.
review¶
Create and manage durable review packages. Managed reviews preserve target identity, reviewer coverage, findings, decisions, learning candidates, and an optional flow relationship instead of leaving review state only in chat output.
--automatic on start, attach and ingest marks the package as an
automatic review run (manifest.automatic: true; review status prints
run mode: automatic review). The mark is a run mode, not a permission mode and
not a way around the gate: review complete and every one of its refusals work
exactly as without it. Inside keryx shell it is what lets a turn run the
review's own commands without a prompt — see
Automatic review.
keryx review attach --flow <id> --target <kind> --ref <ref> [--head <sha>]
[--reviewers a,b] [--report <path>] [--automatic]
keryx review start --target <kind> --ref <ref> [--head <sha>] [--reviewers a,b] [--report <path>] [--automatic]
keryx review ingest --report <path> [--flow <id>] --ref <ref> [--head <sha>] [--automatic]
[--verifications <file|->] [--verification-mode off|annotate|filter]
[--scope <scope.json>] [--blast-radius <blast-radius.json>]
[--refuted <file|->]
[--max-findings <n>] [--spent <usd>] [--spend-ceiling <usd>]
[--parallel <n>] [--outstanding <n>]
keryx review run --autonomous --target pr --ref <pull request URL>
[--budget-minutes N] [--budget-tokens N] [--stall-minutes N] [--out <dir>] [--no-notify] [--json]
keryx review budget [--spent <usd>] [--ceiling <usd>]
[--reviewers a,b] [--parallel <n>] [--outstanding <n>] [--full]
keryx review tier [--scope <scope>] [--fix-attempt <n>] [--forced-strategy-change]
[--findings <n>] [--diff-lines <n>]
[--verifier execution|site-check|reasoning] [--security]
[--session-provider <id>] [--session-model <id>]
[--catalog <file|->] [--json]
keryx review comments collect --repo <owner/repo> --pr <n> --sha <head-sha>
[--self <login>] [--round <n>]
[--out <findings.json>] [--json] [--fixtures <dir>]
keryx review comments reply --repo <owner/repo> --pr <n> --outcomes <file|->
--sha <head-sha> --final [--result <file>] [--review <review-id>]
[--round <n>] [--dry-run] [--self <login>]
[--max-replies <n>] [--max-sentences <n>] [--max-chars <n>]
[--flow-link <url>] [--allow-closed-pr] [--fixtures <dir>]
keryx review bot run --pr <n> [--repo <owner/repo>] [--max-diff-bytes <n>]
[--provider <id>] [--model <id>] [--fixtures <dir>] [--json]
keryx review bot post --pr <n> [--repo <owner/repo>] [--sha <head-sha>] [--review <id>]
[--post] [--fixtures <dir>] [--json]
keryx review metrics [--json] [--refresh] [--fixtures <dir>]
keryx review ci-triage --run <id> [--job <name>] [--test <name>] [--repo <owner/repo>]
[--model <jev-1.13|jev-latest>] [--fixtures <dir>] [--json]
keryx review conform --ref <doc> (--pr <n> | --report <dir> | --diff <ref>)
[--repo <owner/repo>] [--explain] [--threshold <0..1>]
[--model <jev-1.13|jev-latest>] [--fixtures <dir>] [--json]
[--max-hunks <n>] [--max-hunk-calls <n>] [--detail]
keryx review jev-docs (--diff <ref> | --pr <n>) [--max-calls <n>] [--threshold <0..1>]
[--repo <owner/repo>] [--model <jev-1.13|jev-latest>]
[--fixtures <dir>] [--include <glob>]... [--json]
keryx review jev-comments --pr <n> --repo <owner/repo>
[--model <jev-1.13|jev-latest>] [--fixtures <dir>] [--json]
keryx review learn --pr <n> [--dry-run] [--json]
keryx review learn --reviewer <id> [--dry-run] [--json]
keryx review loop --flow <flow-id> [--task <Tn>]
keryx review status <review-id-or-path>
keryx review complete <review-id-or-path>
[--finding <id> --disposition <state> --evidence <ref>]...
keryx review lightweight
keryx review reviewers [--json]
keryx review required [--mode all|diff] [--scope <scope.json>] [--operator-quote "<words>"] [--blast-radius <blast-radius.json>] [--json]
keryx review mutate --test "<command>" --package <review-dir> [--worktree <dir>] [--spec <mutants.json>] [--timeout-ms <n>] [--json]
keryx review severity-audit --package <review-dir> [--dry-run] [--json]
keryx review context-collect --repo <owner/repo> --pr <n> --package <review-dir> [--self <login>] [--checks <gh-checks.json>] [--json]
keryx review compare-reference --reference <report.md> --package <review-dir> [--input <dir>] [--json]
keryx review import --from <package-dir|tree> [--only <glob>]... [--dry-run] [--force] [--allow-flagged] [--json]
keryx review scope [--ref <base>] [--diff <file|->] [--path a,b] [--context <n>] [--json|--scoped-diff] [--append <file>]
keryx review floor [--ref <base>] [--diff <file|->] [--context <n>] [--json] [--report-only]
keryx review blast-radius [--ref <base> | --changed a,b] [--depth <n>] [--max-files <n>]
[--no-related-tests] [--final] [--previous <blast-radius.json>]
[--package <review-dir>] [--json|--brief] [--out <file>]
keryx review slice [--ref <base>] [--diff <file|->] [--context <n>] [--max-bytes <n>] [--wave-bytes <n>] [--out <dir>] [--json]
keryx review dispatch-check --payload <file|-> [--manifest <file>] [--max-bytes <n>] [--package <review-dir>] [--json]
keryx review retry-plan --manifest <file> [--result <file|->] [--reviewer <id>] [--slices a,b] [--covered a,b] [--next-wave]
[--state <file>] [--dry-run] [--json]
keryx review ledger build <input-dir> [--package <review-dir>] [--mode all|diff] [--findings <file>] [--dispositions <file>] [--out <file>] [--dry-run] [--json]
keryx review layers refresh --package <review-dir> [--head <sha>] [--base <ref>] [--write-wiki] [--force] [--json]
keryx review layers status --package <review-dir> [--json]
keryx review layers block --package <review-dir> --files a,b [--max-bytes <n>] [--force] [--json]
keryx review chunks plan|estimate|next|record|merge --package <review-dir> [--chunk <id>] [--status <s> | --result <file|->] [--mode automatic|interactive] [--answer yes|no --quote "<words>"] [--json]
keryx review coverage --package <review-dir> [--json]
keryx review complete ... [--waive-coverage "<operator quote>"]
An unrecognised option is refused, not ignored, and the command exits 1. A flag that is silently dropped writes nothing and still reports success.
--head <sha> is the commit the round ran against, written to
manifest.target.head and read by the review completion gate — which refuses
to complete a flow whose last round cannot say which tree it read. Omitted, it
is git rev-parse HEAD: the tree the reviewers actually read, preferred over a
pr target's own head so that a round run against something other than what
will merge fails the gate instead of passing it. With no checkout to ask and a
pr target, the pull request's head is used instead; with neither, the head is
left off and the gate reports it as unobserved.
review complete on a package bound to a pull request refuses a merged PR or a moved head unless the first line of report.md names the reviewed SHA and says so, and refuses a report with no Evidence level: line (static only — did not run: <what> or ran — <what>); a package ingested before the field existed completes with a warning. review ingest reads each internal finding's quote at its path at the reviewed SHA and refuses one that does not resolve there, and adds a citation not found: <path> line to a dismissal whose rebuttal cites a rule or document that is not in the tree. A research obligation may close as narrowed with a remains text; without it, it stays open.
| Subcommand | Description |
|---|---|
attach |
Create a managed package linked to an existing Task Manager flow. |
start |
Create a standalone managed review package. |
ingest |
Convert an existing report into a managed package, optionally linked to a flow. |
status |
Print mode, status, target, flow link, coverage count, and filter_stats. Exits 1 when filter_stats does not hold together. |
complete |
Validate the package, record what became of its findings, write the learning note for anything dismissed as incorrect, and mark it complete only when required artifacts exist. See below. |
lightweight |
Confirm report-only mode; creates no managed artifacts. |
scope |
Build the bounded review scope deterministically. See below. |
floor |
Report the edits in a diff that lower the bar: a threshold moved the weakening way, a disabled test, a test region that checks fewer things, a new suppression. Exits 1 when it finds any. See below. |
blast-radius |
Compute what the change can break, as opposed to whether it is correct. See below. |
layers |
Refresh the graph, the wiki and the memory index for one review, with no model, and write layers.json. See below. |
chunks |
Plan, estimate and run the blast-radius files the per-pass cap dropped, one chunk at a time. See below. |
coverage |
Print how many in-scope files are covered, covered by a named omission, and uncovered. See below. |
budget |
The spend and concurrency gate, run before dispatch. Exits 1 when the spend ceiling is reached. See below. |
tier |
The model block a dispatch document carries, computed from signals the orchestrator already holds. See below. |
loop |
Loop detection over a flow's review rounds. Exits 1 when repetition escalates. See below. |
stack |
Which reviewers this repository's declared stack calls for. Fails toward including a reviewer: an unreadable, workspace-only or dependency-less manifest runs everything. |
comments |
Collect comments left on the PR by anyone else, and answer them — once, at the end. See below. |
ci-triage |
Advisory-only flaky/infra/real-regression triage for one failed CI run's job, scored by Jev (TypeSafe System One). See below. |
conform |
Check a PR, a review report, or a diff against a reference document's clauses, scored by Jev. See below. |
jev-risk |
ADDITIONAL orchestrator reviewer (engine: jev): a deterministic risk map of every changed hunk, ranked, with a routing hint. See below. |
jev-scenarios |
ADDITIONAL orchestrator reviewer (engine: jev): which user scenarios a diff likely changes. See below. |
jev-docs |
ADDITIONAL orchestrator reviewer (engine: jev): find doc sections that went stale because of a diff. See below. |
jev-comments |
ADDITIONAL orchestrator reviewer (engine: jev): check whether open PR review comments were addressed. See below. |
jev-contract |
ADDITIONAL orchestrator reviewer (engine: jev): check a PR description's own claims, and a linked flow's frozen acceptance criteria, against the diff. See below. |
jev-triage |
Advisory, annotate-only: severity calibration, duplicate-merge candidates, and verifier queue order over a review package's consolidated findings, scored by Jev. See below. |
learn |
Turn collected PR comments from the authors this project configured into a learning proposal for its own local review skill. Reads the collected record; never fetches. See below. |
reviewers |
List bundled and project-local reviewers (keryx review reviewers [--json]). The project half is .metaproject/project-skills/review/<name>/; each entry carries paths + pathsSource, flags, flagWarnings, familyFlags, stackRequires, unresolvedRules, shadowedRules, unresolvedReferences and drift for the orchestrator's filters and dispatch. paths come from metadata.paths, else from the globs in the description plus any literal file path named beside them; a description with no glob gives pathsSource: none (dispatched on every round) even when it names a file. flags come from metadata.flags when it has at least one non-empty entry — entries split on commas/whitespace, trimmed, lower-cased and ---prefixed; one that is still not --name is dropped and reported in flagWarnings (a warning: line in the text output), and the description's flags are not used even if none survives or the list holds only --all (no flags, and no warning for --all) — else from the description; an empty metadata.flags ([], '') leaves the description's flags in force. metadata.flags and metadata.paths accept a comma-separated string, a flow list or a YAML block list; the frontmatter reader accepts a BOM and CRLF, drops trailing # comments, counts only keys directly under metadata:, and reads any shape outside that subset as not declared. drift is clean, changed, missing, or none when no origin was recorded (the text row reads (no recorded origin)). The top-level bundledSource says where the bundled half was read: project (this project's installed .metaproject/skills/gdskills/review), package (that directory is absent, so the keryx package's own review skills are listed — not evidence of what is installed here) or not-found (neither; exit 1). |
required |
Print the reviewers a round must dispatch (keryx review required [--mode all\|diff] [--scope <scope.json>] [--json]). Mode all (the default) is every installed review-* reviewer plus the project reviewers, minus the router, verifier, regression, PR-feedback, greptile and strict entries, minus engine reviewers, minus those the stack scoping rules out, minus path-gated ones no scope-A file matches. Mode diff is the set derived from the changed files plus the project reviewers; it needs --scope with the file list. Each excluded reviewer prints with its reason. keryx review start\|ingest --scope <scope.json> stores the file list as scope-files.json, and keryx review complete refuses a research.json whose dispatch.mode is missing or whose dispatch.selected omits a required reviewer, unless dispatch.override carries operatorQuote and reason. |
mutate |
Run the reviewers' proposed mutants against the tests (keryx review mutate --test "<command>" --package <review-dir>). Each mutant is one exact-text replacement in a scratch copy of the worktree; the worktree is never written, and the baseline must pass first or no mutant runs. A mutant is killed (the tests failed), survived (they passed: no assertion pins that behaviour), invalid (the anchor is missing or not unique) or timeout. The default spec is <review-dir>/research-evidence/mutants.json; results go to research-evidence/mutation-<n>.json and .md. For an attended run, review complete refuses a package with proposed mutants no pass has run, unless dispatch.mutationWaiver carries operatorQuote and reason; a spec with "runnable": false says the tests cannot be run here. |
severity-audit |
Check the severity of findings against what the mutation pass proved (keryx review severity-audit --package <review-dir> [--dry-run] [--json]). A finding that cites mutant:<id> in its evidence is raised to major when that mutant survived a recorded run on a file the review changed and the finding was rated minor, nit, info or low; the change is recorded as severity_audit. A cited mutant no run executed leaves the finding as it is, labelled unproven. It never deletes a finding, and it is separate from the verifier. |
context-collect |
Collect what a reviewer cannot get from the diff (keryx review context-collect --repo <owner/repo> --pr <n> --package <review-dir>): every review comment in full, the round-1 intent, the CI status and the backend pins the pull request names, written to pr-context.json and pr-context.md. Comments by --self are left out. A pull-request review dispatches with pr_context set to the .md, and dispatch-check --pr-context refuses a payload without it. |
compare-reference |
Compare a reference report's items with a package (keryx review compare-reference --reference <report.md> --package <review-dir> [--input <dir>] [--json]), per item: A the role that would find it was not run, B the role ran and nothing matches, C it was seen and lost after the answer (--input supplies the raw reviewer replies), D it was kept, with its severity, E the reference ran a mutant and this package has no mutation pass. |
import |
The review-shaped spelling of keryx skills import --module review: same importer, same rules, module implied (keryx review import --from <package-dir\|tree> [--only <glob>]... [--dry-run] [--force] [--allow-flagged] [--json]). A tree of several packages is refused, with the candidate list, until --only <glob> (repeatable, matched against the package directory name as it is on disk; an empty value is an error) says which ones: every package in module review is dispatched as a reviewer. The package is written under the slug of its directory name, and two selected directories that slug to one destination are refused. A deprecated: true package is skipped in a tree import, a bundled name is skipped unless --force, and a reviewer with no path trigger — no metadata.paths and no glob in its description; a lone literal file path is not one — is imported with a paths: none warning. More warnings can follow a row: a metadata.flags entry dropped as not a flag; a flag that exactly one existing project reviewer carries, which becomes a family flag for both; and, on a --force overwrite, a family flag the new version drops that leaves one other reviewer its only carrier. A package directory with no letter or digit is refused, and so is any destination behind a symlink that resolves outside the project — before anything is written, --dry-run included. Also copies the <dir>/<name>.mdc rules the skills cite from the overlay's rules/: a rule the project lacks goes to .metaproject/rules/<dir>/<name>.mdc; one whose reference is already answered by a file with other content, or a core/ rule keryx ships under the same name, goes to the reference's project slot .metaproject/rules/project/<dir>/<name>.mdc (statuses differs, imported-project; core/x.mdc → rules/project/core/x.mdc), which a reviewer's reference resolves to first. rules/core is rewritten by keryx init, keryx update and the legacy-profile keryx skills install (the manifest form with --target keryx-shell skips an existing file it did not record unless --force); rules/project is written by none of them. A file lying directly in rules/project/ answers only a literal project/<name>.mdc citation. Re-run it over an existing import to fetch only the rules. The security gate runs on every SKILL.md and rule while the import is planned, --dry-run included: a secret is redacted and the row says so, and a file with a prompt-injection finding is refused (would-refuse on a dry run), writes nothing and leaves the rest of the import going, unless --allow-flagged is passed after you have read it (see skills import). That holds with the security module disabled too (the import runs the injection detector itself). A rule cited only by refused packages is not written (skipped — cited only by a refused package). A real run with any refused row prints every row and exits 1; a dry run exits 0. Undo with keryx skills remove review/<name>. |
Target kinds are validated by the runtime. Review packages are stored under the linked flow when attached, or in the managed standalone review location selected by the review service.
review run¶
keryx review run --autonomous --target pr --ref <pull request URL>
[--budget-minutes N] [--budget-tokens N] [--stall-minutes N]
[--out <dir>] [--provider p] [--model m] [--no-notify] [--json]
One review of one pull request with nobody at the keyboard, in the review-autonomous profile
(keryx shell --print in trust mode under --review-system review-auto, source only). It needs no
terminal. It starts the review package, dispatches the reviewers, ingests their findings and closes
the package with review complete.
| Flag | Meaning |
|---|---|
--autonomous |
Required: the only mode. |
--target pr, --ref <URL> |
Required. --ref is https://github.com/<owner>/<repo>/pull/<n>. |
--budget-minutes N, --budget-tokens N |
Stop at a safe point when spent. Exit 4. Default: none. |
--stall-minutes N |
Stop a run with no new event for this long. Exit 5. Default 10. |
--out <dir> |
Where the run directory goes. Default .metaproject/data/review-autonomous/<owner>-<repo>-pr<N>/. |
--provider, --model |
Passed to the child shell. |
--no-notify |
Send no Telegram line at start and end. |
--json |
Print summary.json instead of two lines. |
| Exit code | Status |
|---|---|
| 0 | complete: the package was closed. A re-run on a head already reviewed to the end also exits 0 (reused). |
| 2 | incomplete: ended or stopped (SIGTERM, SIGINT) before the package was closed. |
| 3 | floor: an action that always needs a human was refused and the run could go no further. |
| 4 | budget: --budget-minutes or --budget-tokens was spent. |
| 5 | stalled: no progress for --stall-minutes. |
| 1 | Refused: bad arguments, or another run of the same pull request holds the lock. |
Questions the model asks are answered by the safe default (the recommended option, else the first;
an irreversible one is refused) and each answer is recorded with its basis in answers.ndjson. The
run directory also holds summary.json, report.md, events.ndjson, progress.json, lock.json,
run.json and child.log. A run on the same head resumes an open package; a run on a new head
starts fresh. In keryx shell, /autoreview start|list|stop|arbiter runs and watches the same command; arbiter lists the run's review-arbiter log.
See Review a pull request with nobody at the keyboard.
review comments¶
Comments left on the pull request by other people — and by other bots — are collected every round and answered once, at the end. Not per round: a bot that replies on every round turns one review thread into six, and the reviewer reads the noise before the answer. The work happens continuously; the speaking happens once, after the final round and before the completion gate, so every reply states a settled outcome rather than an intention.
All three sources are read: inline review comments, review submissions, and PR-level discussion. Bot authors are handled identically to humans — a bot reviewer is a reviewer. Only our own identity is excluded, and the collector refuses to run without knowing it, because a set that includes our own replies would have the reply pass answering itself every round.
Each comment becomes a finding with source: "external". Severity is
classified, never invented: a comment on a CHANGES_REQUESTED review starts
at major, everything else at minor, and a comment that cannot be classified
takes the minor floor with the reason recorded rather than a guess.
Two rules that are not conveniences:
- The verifier cannot refute an external finding on its own. A human asked a
question; a machine deciding the question was invalid is not an answer. The
disposition becomes
answered-disagree, and it still owes a reply. - A thread we did not open is never resolved or hidden. Replying is ours; resolving belongs to whoever wrote the comment. Auto-resolving is how a bot silences a person.
| Flag | Sub | Description |
|---|---|---|
--repo <owner/repo>, --pr <n> |
both | The pull request. Required. |
--sha <head-sha> |
both | Required. On collect it is the head the pass READ, written to the record as collected_sha; on reply it is the head the answers are true of, recorded on every handled comment — and it must BE the pull request's head (equal, or a 7+ character prefix), or the pass is refused. 7–40 hex characters on both. collect prints the PR's state and head and warns on a mismatch. |
--self <login> |
both | The identity we are acting as. Resolved from gh api user when omitted, and required with --fixtures. On reply the login recorded at collection time wins. |
--round <n> |
both | The review round. Default 1. |
--out <file> |
collect | Write the external findings (source: "external") as JSON. |
--json |
collect | Machine-readable result, including the state that was written. |
--outcomes <file\|-> |
reply | One disposition and one reply sentence per collected comment. Required — the judgement is the model's; this command enforces the budget, the threading and the once-at-the-end rule. |
--final |
reply | Required. Without it the pass refuses: replying per round turns one thread into six. |
--review <review-id-or-path> |
reply | The managed review package this pass answers for; its target must be this pull request. Posting requires it or --result — a lightweight review is report-only and answers nobody. Not needed with --dry-run. |
--dry-run |
reply | Print the exact requests; post nothing, write nothing. |
--max-replies <n> |
reply | Individual replies before the overflow summary. Default 30. 0 is legal and means one summary stands for everybody. |
--max-sentences <n> |
reply | Sentence budget per reply. Default 2. Below 1 is refused. |
--max-chars <n> |
reply | Character ceiling per reply. Default 600. Below 1 is refused. |
--flow-link <url> |
reply | The flow artifact the overflow summary points at. |
--fixtures <dir> |
both | Answer every read from JSON on disk and record writes without sending them. pull.json is the pull request (state, merged, merged_at, head.sha); without it the state is unknown and reply refuses. |
--allow-closed-pr |
reply | Post to a closed or merged pull request. Off by default: reply refuses a pull request that is not open, and one whose state could not be read. The head must still match --sha. |
collect --sha is what makes the record datable, and the review gate depends
on it: rounds_collected is a count, --round defaults to 1, and nothing else
in the record carries a timestamp, so a state file written at the start of round
1 against a pull request nobody had commented on was indistinguishable from one
written after the last round. Comments posted in between were invisible and the
gate completed the flow over them. The gate now compares collected_sha with the
PR head and reads a mismatch — or a record with no collected_sha — as
unobserved.
Replies are at most two sentences and at most 600 characters, enforced rather
than advised: the over-long version is not reachable from the function that
produces the reply, and a truncation with no link to point at is refused
outright. Both bounds are needed — a 4,000-character reply with one full stop is
one sentence, and a sentence budget alone waves it through. Whole sentences are
dropped first; a lone sentence over the ceiling is cut at a word boundary, with
an ellipsis and the link. The Re <url>: anchor on a top-level reply sits
outside both bounds, because it is an address rather than an explanation.
The sentence counter masks URLs, inline code, markdown link targets and
abbreviations before splitting — but only an abbreviation that is not
sentence-final, i.e. one followed by a lowercase word. etc. Also updated the
docs. is two sentences; etc. and the docs. is one. Masking a sentence-final
abbreviation under-counted, and under-counting is the direction that posts the
wall of text: over-counting only costs a truncation, which is recorded and
carries a link.
Every collected comment ends with exactly one reply and one disposition — silence is not an acceptable outcome, and neither is answering twice.
Idempotent across a kill, not just across a clean restart. The durable record is written around each POST rather than after it: a marker goes down before the request leaves, and is replaced by the settled entry when it returns. A process killed in that window leaves a marker saying which reply was in the air, and the next run resolves it against the pull request itself — the reply is either visible in the thread, in which case GitHub is the record and the entry is completed from it, or it is not, in which case it is sent. Writing only after the post bounds the loss to one comment; it does not close the window.
A record with no reply_url is a comment nobody answered, and all three
readers agree on that: the collector re-offers it, the completion gate counts it
unanswered, and the reply pass posts it. The pass used to skip on row existence
instead, which put a settled row with no reply_url into a state nothing could
clear — offered for ever, never answered, gate violated for good. A row reaching
the pass with no reply is now resolved against the pull request itself before
anything is sent, so the repair cannot answer a reviewer twice. Being past the reply cap
changes how a comment is answered — one summary comment stands for the backlog —
never what was decided about it: each backlogged comment keeps the disposition
the orchestrator reached for it.
The trade-off, stated: a reviewer who comments early waits until the end. That is deliberate — answering with a work-in-progress state that later changes is worse. A comment that blocks progress rather than reporting a problem is escalated to the operator immediately instead.
review learn¶
The join between the comments a pull request collected and this project's own review skill. Keryx ships the mechanism and no people: which reviewers teach a project is a decision that lives in that project, and what they teach never travels back into keryx.
$ keryx review learn --pr 412
Created learning proposal: frontend-orders-review-20260830T101500
Skill: frontend/orders-review
Source: .metaproject/data/gdskills/learning-sources/acme__web__412.json
Record: .metaproject/data/gdskills/learning-sources/acme__web__412.md
Authors: dana-reviews
Comments: 4 used, 7 excluded as unconfigured, 0 with no recorded body
Confidence: high
Proposal: .metaproject/data/gdskills/proposals/frontend-orders-review-20260830T101500.json
...
Nothing was written to the skill. Apply with: keryx skills learn apply <path>
Configuration is .metaproject/review-learning.config.json, and it names the
three things the join needs and nothing else:
{
"schemaVersion": 1,
"skill": "frontend/orders-review",
"repo": "acme/web",
"authors": ["dana-reviews", "sam-architecture"]
}
| Field | Meaning |
|---|---|
skill |
<module>/<skill> — a project skill under .metaproject/project-skills/, registered with keryx skills create. |
repo |
owner/repo whose collected comments teach this skill. |
authors |
The GitHub logins whose comments count. Matching is case-insensitive and otherwise exact — a prefix of a configured login is a different person. |
| Flag | Description |
|---|---|
--pr <n> |
The pull request to learn from. Its comments must already have been collected. |
--reviewer <id> |
Sibling mode (W3): apply an accepted domain: review-conventions learned-pattern record to .metaproject/rules/reviewers/<id>.mdc instead of the --pr path above. --pr is not read in this mode. |
--dry-run |
Compute the proposal (or, with --reviewer, the rendered profile) and write nothing. |
--json |
Machine-readable result, including the selection counts. |
There is no --authors, --skill or --repo flag, deliberately. A flag
would make the configured list a default, and one invocation could teach a
project from somebody it never named.
--reviewer <id> applies a per-reviewer profile, not a per-skill proposal.
The source is an accepted keryx learn record (domain: review-conventions,
reviewerProfile.reviewerId set) — a human already ran keryx learn accept
on it. This mode writes .metaproject/rules/reviewers/<id>.mdc directly: a
semver header comment, the generalized conventions, and a changelog entry.
<id> is always the opaque rv--prefixed hash keryx learn records —
never the reviewer's literal login, which the rendered file is refused from
ever containing.
A project with no config file does not learn. That prints one line and exits
0. It is a supported state, not a warning — a tool that warns about the normal
case teaches people to skip warnings. A config that is present and wrong is a
different answer and is refused.
It reads; it never fetches. The source is the record
keryx review comments collect wrote, which carries each comment's author and,
since this command existed, its text. A learning pass that called GitHub again
would be learning from a pull request that has moved on since the round it claims
to describe. Learning a pull request nobody collected is refused rather than
reported as an empty pass.
Two files are written per pull request, under
.metaproject/data/gdskills/learning-sources/:
<owner>__<repo>__<n>.json— the lessons, and nothing else. Every string in it becomes a candidate lesson, so it carries no prose of its own; a "generated by" line in that file would end up in somebody'sSKILL.md.<owner>__<repo>__<n>.md— the provenance: which record, which commit it was true of, who was configured, and how many comments were excluded. The excluded comments are counted there and quoted nowhere.
The proposal writes nothing. keryx skills learn apply remains the only
writer, and it refuses any target outside .metaproject/project-skills/ — so a
misconfigured project cannot write learned conventions into the shipped templates
under src/gdskills/bundled/. That refusal is in applyLearningProposal, not in
this document.
Seeding a local skill from conventions you already have¶
A team that already keeps a review rule — in a personal rules directory, a wiki
page, a CONVENTIONS.md, a rule file from an older keryx install — does not have
to wait for a pull request to accumulate one.
The content comes from your own copy, outside this repository. Keryx ships no conventions and keeps none: nothing was migrated into it and nothing can be recovered from it. Paste from the file you already hold.
# 1. Create the local skill. It lands under .metaproject/project-skills/,
# which is the only place a learned lesson may ever be written.
keryx skills create src/features/orders --module frontend --name orders-review
# 2. Paste the conventions you already have into its `## Review Lessons`,
# `## Review Checklist` and `## Anti-patterns` sections, and record where
# they came from in `skill-changelog.md`. This step is a copy, by hand, on
# purpose: keryx will not read a path outside the project to do it for you.
# 3. Name the skill and the reviewers who will keep it current.
cat > .metaproject/review-learning.config.json <<'JSON'
{
"schemaVersion": 1,
"skill": "frontend/orders-review",
"repo": "acme/web",
"authors": ["dana-reviews"]
}
JSON
# 4. From here the skill maintains itself, one pull request at a time.
keryx review comments collect --repo acme/web --pr 412 --sha "$(git rev-parse HEAD)"
keryx review learn --pr 412
keryx skills learn apply .metaproject/data/gdskills/proposals/<id>.json
Point a second project at different reviewers and its skill diverges from the first. That is the intended behaviour, not drift: there is no single correct review checklist, and shipping one would mean shipping one team's opinions to everybody.
review scope¶
The scope a review is dispatched over, computed rather than eyeballed. Dropping a lockfile from a review needs no judgement, so it is not left to a model: this is a pure function of the diff and its configuration, with no model call, no network, and no filesystem access beyond reading the diff.
It drops generated, lockfile, snapshot, minified, binary and vendored paths; drops whitespace-only and comment-only hunks; and narrows what remains to the changed hunks plus a context window (default 20 lines, configurable). Hunk-level scope is the point rather than a nicety — review comments anchored to a hunk are acted on several times more often than comments anchored to a whole file.
| Flag | Description |
|---|---|
--ref <base> |
Diff against this base instead of the default merge-base. |
--diff <file\|-> |
Read a unified diff from a file or stdin instead of running git. |
--path a,b |
Path mode: scope these paths rather than a diff. |
--context <n> |
Context lines kept around each retained hunk. |
--json |
Machine-readable scope, including .files for reviewer auto-detection. |
--scoped-diff |
Emit the retained diff itself, ready to hand to a reviewer. |
--reviewers a,b |
Not used for scoping — it is how the cost estimate knows the fan-out. |
--append <file> |
Write the scope and the drop list into a review package's scope.md, replacing a ## Pre-filter scope block already there rather than adding a second. |
The human-readable output ends with an estimate of what the round will cost, before any of it is spent:
### Estimated cost
- ≈ 26,655 prompt tokens of scoped diff per reviewer (characters ÷ 4 — an estimate, not a measurement)
- ≈ 106,620 across 4 reviewers, before their own prompts, tools and any verifier wave
It multiplies by --reviewers because every reviewer receives the scoped diff,
so a per-round figure that ignored the fan-out would understate the one thing
the estimate is printed for. It is labelled an estimate wherever it appears, and
it is deliberately absent from --json and --scoped-diff: those are read by
programs and by reviewers, and neither is a place for prose about money.
Every drop is recorded with its reason, its granularity, and the lines it covered. A scope that silently truncated would read as "we reviewed everything" when it did not, which is the failure this command exists to make impossible.
Keep the --json output and hand it to review ingest --scope: that is the
supported route from the pre-filter to the review record, and unlike --append
it does not depend on two commands hitting the same file in the right order.
Deliberately not detected, so the omission is stated rather than implied:
build/ is reviewed (it is as often hand-written tooling as output), *.d.ts
is reviewed, and package.json / go.mod / Cargo.toml are reviewed — those
are dependency decisions, which is exactly what a reviewer wants beside the
lockfile churn that is dropped. Comment-only detection covers a fixed set of
extensions; an unrecognised extension is always reviewed, detection switches off
entirely for hunks containing a template literal, triple-quoted string or
heredoc, and a comment carrying a tool directive (@ts-expect-error,
eslint-disable, go:build, noqa) is never treated as comment-only, because
it changes behaviour.
review floor¶
A review asks whether the change is correct. It does not ask whether the change
made the checks weaker, and that is a different question: a coverage minimum
moved from 80 to 70, an it.skip added to a flaky test, an assertion deleted
from a test that still passes, a // @ts-expect-error above the line that would
not compile. Each is individually defensible, each is invisible in a green
suite, and together they are how a suite rots.
This makes those four edits visible in the diff that introduces them. Pure,
no model call, over the same scoped regions review scope builds.
| Flag | Description |
|---|---|
--ref <base> |
Widen the diff to the merge base of HEAD and this ref. Uncommitted work is still scanned — this adds committed history to the window, it does not replace the working tree with it. Without it, only uncommitted changes are scanned. |
--diff <file\|-> |
Read a unified diff from a file or stdin instead of running git. |
--context <n> |
Context lines around each change, as for review scope. Default 20. |
--json |
Machine-readable report: outcome, findings, counts.byKind, and scanned. |
--report-only |
Print the same report and exit 0. The adoption path: measure the rate before the guard starts refusing. |
Exit codes, per rules/core/cli-interface-design.mdc: 0 clean, 1 a
finding, 2 the guard could not look. A finding is a question the diff has
not answered, and a question asked with exit 0 is a question nobody answers —
the same choice review budget and review loop make. The command does not
claim any finding is wrong; it claims the diff should say why.
2 is the one a script must not fold into 1. A ref that will not resolve, a
--diff file that will not open and a shallow clone with no merge base all mean
the guard never ran, which is not a pass and not a finding. Under --json that
answer is data on stdout, not prose on stderr:
A clean or finding-bearing run carries "outcome": "scanned" alongside its
scanned counts, so a reader tells the two apart by a field rather than by
parsing English. Getting the invocation wrong — an unknown flag, a --context
that is not a number — stays 1; that is a validation error, not an inability
to tell.
--ref, --base, --diff and --context are also refused when their value is
missing or empty: keryx review floor --diff --json, which is what an
unquoted empty variable expands to in CI, used to read as "no --diff", scan the
working tree instead and report "outcome": "scanned" with exit 0. That is
the one answer this command must never give by accident, so a value-taking flag
whose next token is another flag, absent, or empty exits 1 and scans nothing.
In a --depth 1 clone (which is what actions/checkout gives you by default)
--ref has no merge base to resolve against. The command says so and names the
fix: git fetch --unshallow, or fetch-depth: 0 on the checkout step.
What each detection requires, because a guard that fires on the repair as well as the damage gets switched off:
- Threshold lowered. The removed and added lines must be identical once
every number is erased (a trailing comment is ignored on both sides, so
"lower it and explain on the same line" is not an escape), and the identifier
directly in front of each moved number must name a floor (
coverage,minimum,threshold,precision, …) that moved down, or a ceiling (max,limit,timeout,retries,tolerance, …) that moved up. Only that identifier is read, not the whole line:5.→6.in a renumbered markdown list is silent even though the sentence contains the wordallowed. Finding it is a short walk left from the number rather than one token, so a type annotation (const minCoverage: number = 80), an enclosing key (thresholds: { global: 80 }) and a constant index (minScores[0] = 80) all still name their number — but a comma is not an opener, sof(scores, 80)borrows nothing fromf. A coverage minimum raised is silent, a timeout shortened is silent, and a barerows: 50→rows: 10is silent in both directions: nothing names it. An unnamed number beside a named one no longer silences it, but must move the same way. A ceiling in resource units (maxOutputTokens,maxRows) is a budget and is silent; a floor in the same units (minItems: 3→0) is a demand removed and does fire. Residual noise, accepted:limitandmaxare ordinary words, so a paginationlimitraised does fire. - Test disabled.
.skip,.only,.todo,.failing, vitest's.skipIf/.runIf,xit/xdescribe,@pytest.mark.skip,t.Skip(and friends, on an added line, in statement position, called. Chained modifiers are allowed on either side of the keyword and may carry a paren-free argument list, sotest.concurrent.skip(…),describe.skip.each(table)(…)anddescribe.each(cases).skip(…)all fire; a computed table,describe.each(build(x)).skip(, does not..onlycounts: it disables every other test in the file. A.skipbeing removed never fires, and neither does a marker inside a string, mid-expression, or in a sentence that merely mentions it — the required(is what keeps prose about skipped tests out. - Assertion removed. A count, not a parse: a region of a test file that
ends with fewer assertion-carrying lines than it started with. An assertion
moved a few lines nets to zero; an assertion weakened in place
(
toBe(3)→toBeDefined()) is invisible, and is stated as invisible rather than implied to be covered. - Suppression added.
eslint-disable,@ts-expect-error,@ts-ignore,# type: ignore,# noqa,//nolint,@SuppressWarnings,#[allow(and the rest — on an added line, after a comment opener, with no quote before it. A marker listed in a string array is not a suppression, a suppression being deleted is not a finding, and a suppression that merely moved — removed and re-added byte-identical inside the same region — is not "added". That forgiveness is counted: each removal pays for exactly one identical addition, so a region that deletes one marker and adds three reports two.
It sees only what the pre-filter retained, so a threshold lowered inside a
vendored or generated path is invisible by construction — and it depends on
review scope never dropping a comment that carries a tool directive, which is
why that carve-out exists.
review layers, review chunks and review coverage¶
A review reads three derived layers besides the diff: the code graph, the wiki and the memory. keryx review layers refresh --package <dir> brings them up to date and writes <dir>/layers.json; a managed review start, attach or ingest does it for you, and so does any dispatch-check or blast-radius given --package. It calls no model. It runs the graph build, wiki refresh and memory index (without --embeddings); wiki enrich is the only model step in the wiki and is never run automatically. The wiki refresh is a dry run unless you pass --write-wiki. A graph that is intact at the same head is reused and --force rebuilds it. A graph that failed at the same head is not rebuilt for 5 minutes (counted from the failure, so polling does not extend it); --force or a new head rebuilds it at once. A reader that failed (the wiki, the memory, or the affected-files computation) is reported under unavailable in the layers block with its reason, not as an empty result. A SIGTERM or SIGINT during any phase releases the lock and removes the checkout and the staging directory. One refresh runs per package at a time, and a second one waits for the first for up to 16 minutes.
- The graph is built at the PR head into
<dir>/layers/graph, in a fresh worktree; the tracked graph artifacts, the working tree and the PR are not touched.layers.jsonrecords the commit it was built from, andgraph@<sha7> = headmeans it equals the PR head. - The wiki and the memory are read from the project's working copy.
layers.jsonmarks how far that copy is from the PR head (differs, with the commits and wiki/memory paths between). layers statusprints the same lines plus the coverage counts.layers block --files a,bprints the bounded, deterministiclayersblock a reviewer payload carries for those files: affected files, symbol neighbours, related tests, wiki pages and memory entries, with a command that fetches whatever was cut.- Per reviewer run the package records
layersUsed(graph,wiki,memory,ctx), counted from the reviewer's tool-call log,<input-dir>/tool-calls/<reviewer>.jsonl, one JSON line per call. A run without a log isunknown, never zero, and never blocksledger build,compare-referenceorreview complete.
The blast radius keeps at most 40 files per pass; the rest are dropped. review chunks plan writes <dir>/chunks.json, a deterministic split of every dropped file into chunks (at most 40 files and the slice byte cap), each file in exactly one chunk; no dropped files means no plan. Chunks run one at a time through review-regression on the middle tier: chunks next prints the chunk to dispatch, chunks record takes what it returned and applies the retry plan (a failed chunk is retried per the plan, else stays pending), and chunks merge writes <dir>/chunk-pass.json with the state of each chunk and the merged findings (two on the same file and line count once).
chunks estimate states the chunk count, the files, the estimated tokens and cost at the middle tier, and the duration before a run. The ceiling is 25 passes of 40 files (1,000 files); reviewChunks.maxPasses in routing.config.json moves it. In automatic mode a run at or under the ceiling goes through without a question and a run over it asks once; in interactive mode a run over it waits for --answer yes --quote "<words>". The estimate and the answer are recorded in <dir>/chunk-estimate.json.
review coverage writes <dir>/coverage.json and prints four counts for the changed files and the blast radius together: in scope, covered, covered by a named omission (with the reasons), and uncovered. Every changed file lands in a slice or under a named omission, and coverage.json lists each omitted file with its reason. review complete refuses while any in-scope file is uncovered: it names the count, the first files and the command that runs the missing chunks. The only way past is --waive-coverage "<operator quote>", which records the quote, the time and the files not reviewed in the dispatch record and shows the waiver in the report and the panel. The waiver needs an interactive terminal (a command run from a script or an agent is refused), its quote must be at least 8 characters with a letter or digit once the flag text is removed, and the command asks for confirmation in every permission mode. There is no "unchecked remainder" outcome.
In the shell, /reviews shows one line per review (graph@<sha7> = head|differs, wiki and memory divergence, layers used n/m, unknown k, chunks d/t, uncovered u) in the sidebar and the full lines in the Review depth tab, with l (refresh layers) and c (run missing chunks) in the modal. /review layers prints the same lines.
review blast-radius¶
review scope bounds the change, and a review of it answers is this change
correct? It does not answer did this change break something that was working.
That is a second scope, and it is computed rather than browsed: gdgraph
affected walked outward from every changed file, ranked by edge distance, cut at
a depth and a file cap, closest first.
| Flag | Description |
|---|---|
--ref <base> |
Take the changed-file list from git diff --name-only against this base. |
--changed a,b |
Use this changed-file list instead of asking git. |
--depth <n> |
Edge distance kept. Default 2. |
--max-files <n> |
File cap, closest first. Default 40. |
--no-related-tests |
Skip the naming-related tests of the changed files. |
--package <review-dir> |
Read the graph built for that review at its PR head (refreshed first when the package has none), not the working tree's. |
--previous <file> |
The previous round's --json record; decides whether this round must recompute. |
--final |
This is the final round, so recompute regardless of what the changed-file set did. |
--json |
Machine-readable record: the set, the drops, the unresolved changed files. |
--brief |
The dispatch text a scope-B reviewer is given. |
--out <file> |
Write the record. A .json path writes the JSON; anything else upserts a ## Blast radius block. |
The bounds are not a guess. Measured over 80 commits of this repository against a 1,041-node graph: the depth-2 dependent set is a median of 19 files (p90 65), depth 3 adds eight in the median and doubles the p90, and depth 4 is indistinguishable from depth 3 because the graph saturates. The 40-file cap fires on 25% of those commits and removes only hop-2 entries on all but two of them, so it almost never costs a direct dependent.
Bounded on purpose. Reviewing the whole repository every round is unaffordable and harmful — review quality decays as context grows, and an unbounded second scope makes later rounds worse than earlier ones.
review slice, review dispatch-check, review retry-plan, review ledger¶
A reviewer on a shell host cannot open a path, and one handed the whole diff spends its rounds reading. These three commands keep each reviewer's input small enough to finish; none of them calls a model.
review slice cuts the diff (a git diff, or review scope --scoped-diff output)
into slice-NN.diff files of at most --max-bytes (default 150000) and a
manifest.json whose path values are relative to the manifest. Files of one
directory or module stay together while they fit; a domain over the limit is cut
by file, a file over it by hunk, a hunk over it by line. Data ledgers (csv, tsv,
jsonl, parquet), lockfiles, snapshots, generated files and oversize json are
never sliced: they are listed under omissions with their reason and byte count.
--out defaults to .metaproject/data/review/slices.
The path flags of these three commands (--out, --diff, --manifest, --state,
--result, --payload) must resolve, symlinks followed, inside the project root; a path
outside it is refused before anything is read or written. - is standard input where a
flag accepts it (--diff, --payload, --result) and a file name for the others.
| Flag | Description |
|---|---|
--ref <base> / --diff <file\|-> |
Where the diff comes from. Default: git diff. |
--context <n> |
Context lines kept around a change. |
--max-bytes <n> |
Slice ceiling, at least 200. |
--wave-bytes <n> |
What one reviewer dispatch may total (default 60000, never below --max-bytes). The manifest records it as waveBytes. |
--out <dir> |
Where the slices and the manifest are written. Must resolve (symlinks followed) inside the project root. |
--json |
Print the manifest plus dir and manifestPath. |
review dispatch-check validates reviewer payloads (one object, an array, or
{"dispatches": [...]}) against reviewer-input.schema.json. A diff-scope
payload must name its slices or carry a diff that fits, and what it is
assigned must total at most the manifest's wave budget (--max-bytes overrides it): a role owning more is dispatched in slice waves, and each wave has to fit the reviewer's child deadline. Codes: REVIEWER_INPUT_INVALID,
REVIEWER_INPUT_NO_SLICE, REVIEWER_INPUT_SLICE_UNKNOWN,
REVIEWER_INPUT_TOO_LARGE. Exit 0 ok, 1 refused, 2 payload or manifest
unreadable.
review retry-plan decides what follows a reviewer result. --result is the
reviewer's result JSON, or its saved raw reply (the last REVIEW_RESULT block is
read); its slices list is what the reviewer covered, written as slice ids or as
objects carrying an id ({"id": "slice-03", "status": "DONE", "coverage": "…"}).
An object counts as covered only when its status is absent or DONE, COMPLETE, COMPLETED,
COVERED or OK (any case); NOT_READ, INCOMPLETE, SKIPPED, PARTIAL or any other status leaves
that slice uncovered, and the plan's reason names those slices with their statuses. An entry
with no usable id covers nothing and is named by count in the plan's reason. An attempt is spent only by
a result that covered no slice the reviewer had not covered before. A reviewer that
makes progress is handed the next wave of the slices still uncovered (decision
retry, or wave after a finished result with slices still open) and is not
penalised. A result with nothing new covered cuts the open slices in half
(r<n>-<reviewer>-NN.diff, appended to the manifest) and halves the wave; two such
results in a row, or six failed rounds in all, are not-run and print
- **Not run:** <reviewer> — <status> after a smaller-slice retry; never counted as a clean pass.
The dispatch list is the next wave, at most the manifest's wave budget, and
remaining counts what is left. --next-wave plans the next wave without a result
(--result becomes optional) and counts no stall; --covered a,b adds slice ids
covered by earlier results. Progress is kept in retry-state.json beside the
manifest. --dry-run plans without writing; the exit code is 0 for every decision.
Only the operator can accept a smaller set than that, with their own quote at
review complete.
NEEDS_CONTEXT (or any result with an open needs_context list) is not a finished
scope either. It prints decision context with the questions: answer them and
re-dispatch the uncovered slices. Asking again with nothing new covered is not-run.
review ledger build <input-dir> [--package <review-dir>] writes the
research.json that review ingest --research and review complete demand, from
files only: raw/*.txt (reviewer replies), drivers/*.json, slices/manifest.json
and slices/retry-state.json in the input directory, and scope-files.json,
findings.json in the package. The latest result of each required reviewer decides.
DONE / DONE_WITH_CONCERNS with no open needs_context, and every slice its path
gate requires covered by its dispatched payloads, is a complete source-only run
(executionRequired: false, with the reason). Anything else — NEEDS_CONTEXT,
BLOCKED, INCOMPLETE, missing slices, no result, a retry-state not-run — stays
incomplete or not-run: the builder never declares a reviewer complete on its own
word. Each raw finding becomes an obligation that is closed only by a matching
canonical finding (same reviewer and id) or by a --dispositions entry
{id, status: finding|refuted|out-of-scope, evidence, reason, finding?}; otherwise it
stays unresolved. scopeReviewed and rawReconciled are true only when
established. The command prints READY, or NOT READY with every gap and its next
step (the exact retry-plan --next-wave command with the waves left, the uncovered slices, the disposition to add),
using the gate's own validators; exit 1 when not ready, so
ledger build && ingest --research stops. Flags: --package, --mode all|diff,
--findings, --dispositions (default: the obligations already in research.json),
--out (default <package>/research.json), --dry-run, --json.
drivers/<reviewer>.json records one dispatch:
{"reviewer", "slices": [...], "result": "raw/<reviewer>.txt", "definition": "<path of the reviewer's SKILL.md>"}.
The rule file is taken from definition, or from a skill_path at the top level or
inside a payload object, whichever names a file that exists; a reviewer with none
stays a named gap (no resolved rule file). Only reviewer replies belong in raw/.
A file there with no REVIEW_RESULT block, that no driver names as its result, and
whose name does not start with a dispatched or required reviewer's id is not a reply:
the ledger lists it under ignored as set aside: …, records it in setAside in
research.json, and leaves it where it is, never deleting it. A driver's slices is
the list of slices handed to the reviewer, so a status on one of its entries is not
read; an entry with no usable id there is reported as a gap by count, and none of that
dispatch's slices counts as handed out until it is fixed. A file that holds a REVIEW_RESULT block, or is named by a driver, still
has to be reconciled.
The set is under regression check, not under review, and that is refused in
code rather than discouraged in prose. Three rules, and every one of them is a
fact about the claim rather than about who made it: outside-set (the file is in
neither the computed set nor the changed set), non-regression-severity (below
the major floor — under the canonical rubric minor states that the code
behaves correctly and info names neither a trigger nor an outcome), and
no-link-to-change (nothing in the finding, its anchor included, names a changed
file, module or symbol). There is deliberately no rule that reads the
reviewer's name: one existed, and because it ran after the severity floor it
could only ever refuse major and blocker findings — including a genuine
broken-build regression, on the grounds that the reviewer who spotted it usually
asks a different question. Rejections are recorded with their reason, never
deleted.
Every file the cap removed is recorded, with its hop and its dependency path back to the change. A truncation nobody can see reads afterwards as "we checked everything".
An empty radius is not a clean one. The graph indexes code, so a changed
Markdown, JSON or shell file has no dependents to walk; those are reported under
unresolved with the reason, because "the graph could not answer" and "nothing
depends on this" are different facts. Runtime edges — a spawned process, a file
one module writes and another reads, a string-keyed registry — are invisible to
it, and so is the reverse direction: affected walks dependents, not
dependencies.
Requires a built graph; run keryx gdgraph build first. An absent graph is
refused rather than reported as an empty radius.
Caps — findings, spend, concurrency¶
Nothing in the review pipeline was capped: budget.max_findings was required by
the schema with no default anywhere, there was no cost ceiling, and reviewers
were dispatched in parallel with no wave size while review-orchestrator itself
runs inline in the main session, next to the subagents that session has already
dispatched.
The defaults are in code (src/review/caps.ts), so a caller that says nothing
still gets a bound.
| Cap | Default | Flag | What it does |
|---|---|---|---|
| Findings | 10 per reviewer, blockers exempt | --max-findings <n> |
Truncates a reviewer's ordinary findings. blocker and blocking_merge findings are exempt and do not consume the budget. |
| Spend | 3.00 USD per round | --spent <usd>, --spend-ceiling <usd> |
Stops and asks rather than proceeding. |
| Concurrency | Waves split by diff domain (logic and security, then frontend and backend, then style and tests); 10 reviewers at most per wave for --all and a full review |
--parallel <n>, --outstanding <n> |
Splits the reviewer set into dispatch waves. A reviewer that returns STATUS: RATE_LIMITED halves the wave, is requeued and waits out retry-after (at most 300 seconds); after two halvings the round is BLOCKED rate_limited and names the queue left. |
Every cap records what it dropped, in the package's scope.md under
## Caps and on the terminal — a count, and for the findings cap the id of every
truncated finding. A cap that truncates in silence reads as "there was nothing
more". A cap that did not run prints not recorded, never 0: "dropped nothing"
and "never ran" are different facts.
The findings cap runs over the reported findings only, never over the dismissal
records reaching the package through --refuted or through a refuted verdict.
Those are the record of what was raised and then dismissed; truncating them would
rebuild a corpus of triage survivors, which measures 100% precision whatever the
reviewers got right.
The spend cap is currency, not tokens. A token count is not comparable across models — the same 200k tokens differ by more than an order of magnitude in cost between the cheapest and the most capable model in one vendor's line-up — so a token ceiling is either inert or ruinous depending on an unrecorded model choice. Currency is the unit the decision is made in and the unit the operator's budget is denominated in, and token counts convert to it while the reverse does not. 3.00 USD follows SWE-agent's $3/instance cap; a keryx review round is one target and one reviewer fan-out, the analogue of one instance.
The concurrency cap does not bind the session's total on its own, and says so.
keryx is a CLI invoked once per command: it cannot observe the subagents the main
session still has running, and there is no lease between them and this dispatch.
--outstanding <n> is the main session declaring how many it has running, which
shrinks the effective wave; it is a declaration, not an observation, and nothing
verifies it. The record therefore
carries holds_across_nesting: yes (against the declared count) or no, rather
than implying a guarantee that does not exist.
review ci-triage¶
Advisory-only triage for a failed CI run's job(s): flaky, infra, or real regression, scored by Jev (TypeSafe System One) over a redacted, bounded excerpt of the job's own log plus a small block of deterministic signals computed before Jev is ever asked. Flow 306 (Jev client, opt-in gate, advisory rendering); flow 307 (signals, every-failed-job default, the evaluation harness, this section's measured-accuracy numbers).
| Flag | Description |
|---|---|
--run <id> |
Required (unless --eval). The CI run id (e.g. a GitHub Actions run id). |
--job <name> |
Narrows to one of the run's jobs (reaches it directly even when it is beyond the MAX_JOBS_TRIAGED cap below). Omitted, every job whose conclusion is failure is triaged (flow 307, AC3), up to the cap — one verdict each. |
--test <name> |
Override the failing test name instead of the best-effort extraction from the log excerpt. Only applied when exactly one job is in scope. |
--repo <owner/repo> |
Passed to the live gh adapter; omitted, gh resolves the repository from the current checkout. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--fixtures <dir> |
Answers BOTH the CI read and the Jev call from files on disk (ci-run-info.json, ci-failed-log.txt, optional ci-history.json, ci-attempts.json, ci-changed-files.json, ci-runs-by-head-sha.json, ci-related-runs.json, jev-response.json) — no real gh call, no real network call. |
--json |
Prints {results, notTriaged}: results is an array, one entry per triaged job, each {runId, job, testName, verdict, usage}; notTriaged names any failed jobs beyond the MAX_JOBS_TRIAGED cap (empty when nothing was skipped). |
--eval <file> |
Flow 307 (AC5/AC6). Replays a labelled evaluation manifest (JSON, {cases: [{id, runId, job, truth, fixturesDir}]}) and reports before (flow 306: log only) and after (flow 307: log + signals) accuracy and cost, plus a per-case line. Offline (fixtures) by default. See below. |
--live |
Only with --eval: replay against the real gh/Jev instead of each case's fixturesDir — the opt-in gate and credential check both apply, same as a plain --run. |
A bounded number of jobs per run (flow 307 review, MAX_JOBS_TRIAGED = 10).
Without --job, at most 10 failed jobs of one run are triaged — each job costs
one Jev call and (with signals) a bounded number of extra gh reads, so an
unbounded number of failed jobs no longer means an unbounded number of calls.
Jobs beyond the cap are listed rather than silently triaged or silently
dropped: notTriaged in the --json output, and a
not triaged (cap 10; use --job) line in the text output. --job <name>
still reaches any one of them directly. The failed-step log and the
run-level signals (priorAttempts/changedFiles/runsForHeadSha) are each
read once per run and reused across every job triaged, rather than
re-fetched per job.
review jev-risk¶
Flow 332. An ADDITIONAL orchestrator reviewer — never replacing any other —
that builds a RISK MAP of the diff: for every changed hunk, keryx computes
deterministic facts first (a path class — auth/permissions, crypto,
migrations, schema, public API, config, concurrency primitives, IO —
exported symbols touched, lines changed, and whether a test file elsewhere
in the diff touches the same module), then asks Jev one noul per risk
dimension (security-sensitive, data/migration, public-API/contract change,
concurrency, error-handling). Jev supplies only five probabilities per hunk;
keryx ranks and writes every word of every finding. Dispatched by
review-orchestrator itself (Wave B), as a CLI call rather than an LLM
sub-agent — keryx review reviewers --json marks it "engine": "jev".
keryx review jev-risk --scope scope.json --json
keryx review jev-risk --pr 712 --repo MrCipherSmith/keryx --json
| Flag | Description |
|---|---|
--diff <ref> | --pr <n> | --scope <scope.json> |
Exactly one is required. --scope takes the whole keryx review scope --json document (the same file every other reviewer's dispatch reads). --diff/--pr build the scope themselves via buildReviewScope. |
--max-calls <n> |
Caps how many (hunk, risk dimension) pairs are scored, default 150 (five dimensions x 30 hunks). Regions are scored in diff order; anything beyond the cap is reported skipped, never silently. |
--threshold <0..1> |
Below this combined-risk probability a hunk produces neither a finding nor a routing hint. Default 0.7. |
--repo <owner/repo> |
Passed to the live gh adapter for --pr. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--fixtures <dir> |
Answers the pr-kind read (pr.json) and every Jev call (jev-responses.json, a JSON array consumed in call order) from files on disk — no real gh call, no real network. |
--json |
Prints a REVIEW_RESULT-shaped object (status, reviewer: "review-jev-risk", summary, findings, stats, plus ranked, routingHints, tokens/selection) conforming to reviewer-finding.schema.json. |
A finding fires only above threshold AND with no nearby test — the same
fail-toward-silence discipline review conform's hunk-kind clauses use,
applied to a different fact. Severity is capped at info/minor: this
flags attention, it never asserts a defect. routingHints additionally
lists hunks above threshold whose security or concurrency dimension crossed
it, each naming a suggestedReviewer
(review-security-code/review-highload) — the orchestrator's own signal
to dispatch a specialist even when the hunk's path alone would not have
triggered it (see review-orchestrator/SKILL.md's "CLI-engine reviewers"
section).
Opt-in, and named as a privacy decision. Disabled by default. A project
enables it with review.jev.risk: true in .metaproject/tasks.config.json.
Every hunk sent to Jev is redacted first (src/security/service.ts), the
same floor review conform/review ci-triage already apply. With the
setting off, or with no OpenRouter credential, the command refuses before
any read and makes no network call.
The /risk slash command runs the same check over the working diff and
prints the ranked map, any routing hints, and any findings into the
transcript.
review jev-scenarios¶
Flow 332. An ADDITIONAL orchestrator reviewer — never replacing any other —
that performs a FUNCTIONAL review: which user scenarios a PR likely changes.
Scenarios are gathered deterministically from three sources — gdwiki
user-scenario pages (.metaproject/wiki/user-scenarios/**), PRD
requirement/scenario sections (docs/requirements/**), and README/docs "how
to" sections (README.md, docs/docs/**) — each already carrying the code
it links to. For each scenario whose linked code the diff touches (a fact),
Jev answers one noul: "does this change alter this scenario's behaviour?".
Jev supplies only that probability; keryx writes every word of every
finding. Dispatched by review-orchestrator itself (Wave B), as a CLI call
rather than an LLM sub-agent — keryx review reviewers --json marks it
"engine": "jev".
keryx review jev-scenarios --scope scope.json --json
keryx review jev-scenarios --pr 712 --repo MrCipherSmith/keryx --json
| Flag | Description |
|---|---|
--diff <ref> | --pr <n> | --scope <scope.json> |
Exactly one is required. --scope takes the whole keryx review scope --json document — only its files array is read (the full changed-file list, for the "touched link" fact). --diff/--pr build that list themselves via buildReviewScope. |
--max-calls <n> |
Caps how many scenarios (each with at least one touched link) are checked, default 150. Selection is a deterministic (by scenario id) prefix; anything beyond the cap is reported skipped. |
--threshold <0..1> |
Below this Jev probability a scenario is neither on the manual-check list nor a finding candidate. Default 0.5. |
--repo <owner/repo> |
Passed to the live gh adapter for --pr. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--fixtures <dir> |
Answers the pr-kind read (pr.json) and every Jev call (jev-responses.json) from files on disk — no real gh call, no real network. |
--json |
Prints a REVIEW_RESULT-shaped object (status, reviewer: "review-jev-scenarios", summary, findings, stats, plus checklist, tokens/selection/scenarioSources) conforming to reviewer-finding.schema.json. |
Two separate outputs, not one. checklist is every likely-affected
scenario at/above threshold, ranked, with its touched links as evidence — a
manual-verification list for a human to walk, regardless of whether it
produced a finding. findings is the narrower minor-severity subset: a
likely-affected scenario with no test in the diff covering it (a fact).
Opt-in, and named as a privacy decision. Disabled by default. A project
enables it with review.jev.scenarios: true in
.metaproject/tasks.config.json. Every scenario's text sent to Jev is
redacted first (src/security/service.ts). With the setting off, or with no
OpenRouter credential, the command refuses before any read and makes no
network call.
The /scenarios slash command runs the same check over the working diff and
prints the manual-check list and any findings into the transcript.
review jev-docs¶
Flow 333. An ADDITIONAL orchestrator reviewer — never replacing any other —
that finds documentation sections that went STALE because of a diff. The
default doc corpus is USER-FACING documentation only: docs/**, the root
README* (never CHANGELOG*), and gdwiki pages (.metaproject/wiki/**) —
.metaproject/skills/**/.metaproject/rules/**/rules/** are excluded by
default (--include <glob>, repeatable, widens the corpus back out).
Sections are linked to code DETERMINISTICALLY (an explicit path, a
backtick-quoted symbol that appears in one of the diff's own hunks, or a
keryx <verb> invocation whose command file the diff changed); only a
section linked to code the diff CHANGES, and that the diff does not itself
edit, is a candidate. Candidates are RANKED by link strength — path mention
symbol mention > verb mention; more distinct links to changed code within the same kind rank higher — never alphabetically, then asked one Jev
noul"is this section now inaccurate?" question per selected section, up to--max-calls(default 30) and 8 per doc file. A CLI flag renamed or removed in the diff but still mentioned by an untouched section is flagged with NO Jev call at all. Likereview jev-rules, this is dispatched byreview-orchestratoritself (Wave B), as a CLI call rather than an LLM sub-agent —keryx review reviewers --jsonmarks it"engine": "jev".
keryx review jev-docs --diff HEAD~1 --json
keryx review jev-docs --pr 712 --repo MrCipherSmith/keryx --max-calls 20 --json
keryx review jev-docs --diff HEAD~1 --include ".metaproject/skills/**" --json
| Flag | Description |
|---|---|
--diff <ref> | --pr <n> |
Exactly one is required. |
--max-calls <n> |
Caps how many linked sections are scored by Jev, default 30, plus a fixed cap of 8 per doc file so one large file cannot fill the whole budget. Anything beyond either cap is reported dropped (selection.droppedSections), never silently. The deterministic flag check runs regardless of this cap. |
--threshold <0..1> |
Below this Jev probability a section is not reported. Default 0.5. |
--repo <owner/repo> |
Passed to the live gh adapter for --pr. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--include <glob> |
Repeatable. Widens the default user-facing-only doc corpus (docs/**, README*, .metaproject/wiki/**) back out — e.g. --include ".metaproject/skills/**" to also score skill/rule prose. |
--fixtures <dir> |
Answers the pr-kind read (pr.json) and every Jev call (jev-responses.json) from files on disk — no real gh call, no real network. |
--json |
Prints a REVIEW_RESULT-shaped object (status, reviewer: "review-jev-docs", summary, findings, stats, plus tokens/selection, including selection.rankingBasis) conforming to reviewer-finding.schema.json. |
Opt-in, and named as a privacy decision. Disabled by default. A project
enables it with review.jev.docs: true in .metaproject/tasks.config.json.
Every doc-section excerpt and hunk sent to Jev is redacted first
(src/security/service.ts). With the setting off, or with no OpenRouter
credential, the command refuses before any doc read and makes no network
call.
review jev-comments¶
Flow 333. An ADDITIONAL orchestrator reviewer — never replacing any other,
and never posting or resolving anything — that checks whether open PR review
comments were ADDRESSED. Comments come from the EXISTING ledger
(.metaproject/reviews/pr-comments/*.json, written by keryx review comments
collect); this command never re-collects from GitHub. Per open comment it
computes deterministic facts (commits after the comment touching its file,
the thread's resolved flag — best-effort via one read-only GraphQL query,
"unknown" on any failure — and its replies already on the ledger), then
asks Jev exactly one choice question among resolved-by-fix /
still-open / not-actionable / needs-escalation. Only still-open
(severity minor) and needs-escalation (severity major) produce
findings. Every comment's choice is also written to an advisory-label cache
that keryx review comments reply prints, informationally, next to its own
output — it never changes what that command sends.
| Flag | Description |
|---|---|
--pr <n> |
Required. |
--repo <owner/repo> |
Required — identifies both the ledger file and the live gh adapter. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--fixtures <dir> |
Answers the pr-kind read (pr.json), the commit/thread-resolution facts (git-facts.json), and every Jev call (jev-responses.json) from files on disk — no real gh/git call, no real network. The ledger itself is always read from the real project directory; fixtures never replace it. |
--json |
Prints a REVIEW_RESULT-shaped object (status, reviewer: "review-jev-comments", summary, findings, stats, plus tokens/openComments) conforming to reviewer-finding.schema.json. |
Opt-in, and named as a privacy decision. Disabled by default. A project
enables it with review.jev.comments: true in
.metaproject/tasks.config.json. Every comment body, reply, and hunk sent to
Jev is redacted first (src/security/service.ts). With the setting off, with
no OpenRouter credential, or with no comment ledger yet collected for
--repo/--pr, the command refuses before any read and makes no network
call.
review jev-contract¶
Flow 335. An ADDITIONAL orchestrator reviewer — never replacing any other —
that checks a PR DESCRIPTION's own CLAIMS, and (when a flow is linked) that
flow's FROZEN acceptance criteria, against the diff. Claims are extracted
deterministically from the description: every bullet/numbered-list item, plus
every prose sentence carrying a verb cue (adds, fixes, removes, "does not
change", tests). For each claim, keryx computes deterministic facts FIRST —
named files/symbols/flags present in the diff, test files touched (for a
"tests added" claim), exported-symbol changes anywhere in the diff (for a "no
API change" claim) — then asks Jev exactly one noul: "does the diff support
this claim?". A claim the facts CONTRADICT (e.g. "no API change" with an
exported symbol touched) is major, with class_scope, regardless of the
noul answer; an unsupported claim below threshold is minor. The linked-flow
track reuses flow 328's check-ac.ts wholesale (via runCheckAc) — no
criterion-checking logic is reimplemented. Like review jev-risk, this is
dispatched by review-orchestrator itself (Wave B), as a CLI call rather than
an LLM sub-agent — keryx review reviewers --json marks it "engine": "jev".
When the opt-in is on, it replaces the orchestrator's by-eye Stage 1
"description vs diff" judgement; the by-eye check is the fallback when it is
off.
keryx review jev-contract --pr 712 --repo MrCipherSmith/keryx --json
keryx review jev-contract --pr 712 --flow 335 --repo MrCipherSmith/keryx --json
keryx review jev-contract --diff HEAD~1 --json
| Flag | Description |
|---|---|
--diff <ref> | --pr <n> |
Exactly one is required. --diff has no PR description, so the claims track reports zero claims. |
--flow <id> |
Optional. When given, that flow's frozen acceptance criteria are checked against the same diff, reusing keryx flow check-ac's own pipeline. |
--max-calls <n> |
Caps how many claims are scored by Jev, default 30. Anything beyond is reported dropped (budget.claimsSkipped), never silently. |
--threshold <0..1> |
Below this Jev probability an unsupported claim is reported. Default 0.5. |
--repo <owner/repo> |
Passed to the live gh adapter for --pr. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--fixtures <dir> |
Answers the pr-kind read (pr.json) and every Jev call (jev-responses.json) from files on disk — no real gh call, no real network. |
--json |
Prints a REVIEW_RESULT-shaped object (status, reviewer: "review-jev-contract", summary, findings, stats, plus claims, budget, tokens, and — when --flow was given — acCheck) conforming to reviewer-finding.schema.json. |
Opt-in, and named as a privacy decision. Disabled by default. A project
enables it with review.jev.contract: true in
.metaproject/tasks.config.json. Every claim and matched hunk sent to Jev is
redacted first (src/security/service.ts). With the setting off, or with no
OpenRouter credential, the command refuses before any read and makes no
network call.
The /contract slash command runs the same claim check over the working diff
(no PR description, so zero claims) and prints the result into the
transcript.
review jev-triage¶
Flow 340. An ADVISORY, ANNOTATE-ONLY pass the orchestrator runs over a review
package's CONSOLIDATED findings, after the Sub-Agent Report Quality Gate and
before Wave C verification (review-orchestrator/SKILL.md). It never drops
or demotes a finding by itself — every field it produces is an annotation.
Three tracks, one Jev noul per item:
- Severity calibration: for every blocker/major finding, does it name a
concrete trigger AND a concrete observable outcome — the orchestrator's own
canonical
major/minor/infoboundary test? Recorded asseverity_check: {p, flagged}(flaggedisp < threshold, default0.4) — never an auto-demotion. - Duplicate-merge candidates: pairs built deterministically, never a
blind scan — only findings sharing a file, with overlapping line ranges, or
with overlapping quotes. One noul per pair: same underlying defect at the
same site? Recorded as
merge_candidateswithp— nothing is ever silently merged. - Verifier queue order: one noul per finding — does the evidence
plausibly follow from the quoted code? Recorded as
verify_order, sorted lowest-plausibility-first. Prioritisation only; never skips verification.
status is DONE_WITH_CONCERNS — never DONE — when any severity_check is
flagged OR any merge_candidates pair scores p >= 0.5
(LIKELY_DUPLICATE_THRESHOLD, src/commands/review-jev-triage.ts). Live-check
calibration found same-file pairs that were NOT duplicates scoring around
0.6, so a merge candidate above the threshold is a prompt to look, never a
merge — the status flip is a nudge to read the pair, not a verdict that the
findings are duplicates.
keryx review jev-triage --report .metaproject/flows/327-*/reviews/327-r01 --json
keryx review jev-triage --report ./findings.json --max-calls 20 --json
| Flag | Description |
|---|---|
--report <dir\|findings.json> |
Required. A review package directory (its findings.json is read) or a bare findings JSON file/array. Always read from the real project directory — --fixtures never stands in for it. |
--max-calls <n> |
Caps how many items (across all three tracks, severity first) are scored by Jev, default 30. Anything beyond is reported dropped (budget.itemsSkipped), never silently. |
--threshold <0..1> |
Below this Jev probability a severity check is flagged. Default 0.4. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--fixtures <dir> |
Answers every Jev call (jev-responses.json) from a file on disk — no real network call. |
--json |
Prints {status, reviewer: "review-jev-triage", summary, annotations: {severity_check, merge_candidates, verify_order}, budget, tokens}. |
Opt-in, and named as a privacy decision. Disabled by default. A project
enables it with review.jev.triage: true in .metaproject/tasks.config.json.
Every finding's problem/evidence/quote sent to Jev is redacted first
(src/security/service.ts). Batches stay conservative — at most 40% of the
64k token budget and at most 3 items per batch, with a real vendor
max_tokens_exceeded (HTTP 400) retried once, split in half. With the
setting off, or with no OpenRouter credential, the command refuses before any
read and makes no network call.
The /triage slash command runs the same triage over the latest review
package in the current flow and prints the annotations into the transcript.
review jev-select¶
Flow 344. An ADVISORY, pre-dispatch filter — not itself a reviewer, and it
never produces a findings array — over the candidate reviewer set
review-orchestrator is about to dispatch. For each candidate (bundled +
project reviewers, the same source keryx review reviewers --json reads), it
asks Jev one noul: given a compact diff summary (changed paths, file types,
a sample of hunks trimmed to budget) and the reviewer's own routing
description, is this reviewer applicable to this diff / likely to find
something? Reviewer (sub-agent) SELECTION is the cost lever a live benchmark
named as most promising and, unlike CI triage or review-jev-risk/contract/
rules, it has not been measured yet — so the policy is deliberately
recall-first: a candidate is skipped only when its probability is below
review.jev.select_skip_below (default 0.15), the Wave A core safety set
(review-logic, review-architecture, review-security-code,
review-highload) is never skipped whatever Jev answers, and any error — no
opt-in, no credential, a failed call — keeps EVERY remaining candidate rather
than dropping one. See docs/docs/jev-in-review.md for the full numbers.
keryx review jev-select --ref main --json
keryx review jev-select --diff pr.diff --json
echo "$DIFF" | keryx review jev-select --diff - --json
| Flag | Description |
|---|---|
--diff <file> | - | --ref <base> |
Exactly one is required. --diff reads a unified diff from a file or stdin; --ref runs git diff <base> itself. |
--reviewers <file> | - |
Overrides the candidate set with a prior keryx review reviewers --json capture (or an array of {id, description}), instead of re-reading the installed tree. |
--skip-below <0..1> |
Overrides review.jev.select_skip_below for this run. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--fixtures <dir> |
Answers every Jev call (jev-responses.json) and, optionally, the candidate set (reviewers.json) from files on disk — no real network call, and the opt-in/credential gates are bypassed (same convention as every other review jev-* command's --fixtures). |
--out <path> |
Writes the decisions into path — a .json file gets {decisions, skipBelow, summary}; anything else gets a ## Jev reviewer selection (advisory) markdown block upserted (replacing any earlier one, never appending a second). |
--json |
Prints {decisions: [{reviewer, probability, decision: "keep"\|"skip", reason}], skipBelow, summary, target}. |
Opt-in with review.jev.select: true, and it FAILS OPEN — the one
review.jev.* gate that does not refuse. With the opt-in off, no
credential, or any Jev call failing partway through a batch, every candidate
is decided keep with a reason saying why, and the command still exits 0.
An unmeasured lever must never cost reviewer coverage on its own account.
review-orchestrator (Step 5c) records every decision in the report's scope
section, including the skips, so a later round can check whether a skipped
reviewer's domain surfaced a real finding anyway.
review jev-profile¶
Flow 344. The recommended-profile helper: every review.jev.* key this
benchmark produced a verdict for, in one place, next to the project's current
value — so a project does not have to read multiple flow journals to know
which Jev review steps are worth turning on.
keryx review jev-profile show
keryx review jev-profile --apply recommended
keryx review jev-profile --apply recommended --json
| Flag | Description |
|---|---|
show (bare, or --apply show) |
Prints the current review.jev.* state next to each key's measured verdict. Never writes. |
--apply recommended |
Merge-writes the recommended profile into .metaproject/tasks.config.json: ci_triage: true, select: true, edit_guard: true (read by the separate keryx review jev-edit-guard feature — this command only sets the key), and risk/contract/rules/scenarios/docs/comments: false. Every other key in the file, at every level, is preserved untouched. |
--json |
Prints {current, applied, path?}. |
review conform¶
Reference-document conformance mode (flow 308): a rules file, a skill, or a
project skill is split deterministically into clauses (no model call), each
tagged state_kind: pr|report|hunk and checkable. Every checkable clause is
scored by Jev against DETERMINISTIC FACTS keryx computes first — PR body
sections present/non-empty and hand-written size (via src/review/scope.ts)
for pr; a report's section order and whether every finding records the
fields the document requires (e.g. severity, evidence, a file anchor) for report; the hunk itself for hunk —
placed above the redacted state. A not-checkable clause (a live/manual step,
or a reviewer-process obligation no artefact records) is always listed, never
sent to Jev.
| Flag | Description |
|---|---|
--ref <doc> |
Required. Path to the reference document (a rules file, a skill, or a project skill). |
--pr <n> |
Score pr-kind clauses against that pull request's title/body, and hunk-kind clauses against its diff. Exactly one of --pr/--report/--diff is required. |
--report <dir> |
Score report-kind clauses against an existing review package's own report.md/findings.json — no new artifact format. |
--diff <ref> |
Score hunk-kind clauses against git diff <ref>. |
--repo <owner/repo> |
Passed to the live gh adapter for --pr; omitted, gh resolves the repository from the current checkout. |
--explain |
Sends every clause scored below --threshold to the model the routing table assigns the review category (the session model when unset), citing the clause id and the evidence. Labelled ADVISORY; never written to the PR or to findings.json. |
--threshold <0..1> |
Below this Jev probability a clause is likely-violated (and, with --explain, explained). Default 0.5. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model. |
--fixtures <dir> |
Answers the pr-kind read (pr.json), every Jev call (jev-response.json), and (with --explain) the explanation pass (explain-response.json) — no real gh call, no real network. |
--json |
Prints {ref, target, threshold, clauses, usage, hunkBudget?} instead of the human-readable report. Each clauses[] entry carries clause_id, state_kind, status, and (hunk-kind only) hunksJudged/hunksBelowThreshold and, only when --max-hunk-calls cut that clause down to fewer hunks than the diff has, hunksTotal (the "N" in "judged on K of N"). hunks (the full per-hunk detail) is present only for hunk-kind clauses — a pr/report-kind clause is never scored per-hunk, so it no longer carries an always-empty hunks: [] (flow 326). hunkBudget ({maxHunkCalls, totalHunks, hunksJudged, hunksSkipped, truncatedClauses}) is present only when --max-hunk-calls actually truncated something this run. See the example below. |
--max-hunks <n> |
Flow 326. How many further violating hunk locations a hunk-kind clause's row shows, beyond the single worst one. Default 3. |
--max-hunk-calls <n> |
Flow 326. Caps hunk × clause Jev questions for the WHOLE run. With enough budget for every hunk-kind clause to get at least one full share (--max-hunk-calls >= hunk-kind-clause-count), every clause gets the same floor(--max-hunk-calls / hunk-kind-clause-count) region budget, and the hunks kept are the FIRST that many, in diff order, per clause. With a SMALLER budget than the clause count, the per-clause floor takes over: while the budget allows, each clause (in the order its [state:hunk] line appears in the reference document) gets 1 judged hunk before any clause gets a 2nd — so --max-hunk-calls 1 with 2 hunk-kind clauses judges the FIRST clause on its first hunk and leaves the second at 0, rather than rounding both down to 0. At --max-hunk-calls 0, every hunk-kind clause gets 0 judged hunks. A clause left at 0 this way is never dropped from the report — it appears with status: "not-evaluated" and a reason naming the budget ("skipped by --max-hunk-calls (0 of N hunks judged)"); a clause judged on a subset keeps its usual status but carries the hunksTotal/"judged on K of N hunks" marker described above (text, JSON, and the /conform TUI). Default 40. |
--detail |
Flow 326. Prints the full per-hunk breakdown (every judged hunk's status/location/probability) under each hunk-kind clause's row in the TEXT report. --json always nests this detail, with or without --detail. |
One row per clause, not one row per hunk × clause (flow 326). A live run
of keryx review conform against PR #712 of this repository, before this
flow, printed 271 rows — one per (hunk, hunk-kind clause) pair, unreadable
for any PR with more than a handful of hunks. The report is now aggregated
per clause: a hunk-kind clause's row names how many hunks were judged and how
many fell below --threshold, the single worst hunk (its location and Jev
probability), and up to --max-hunks further violating hunk locations —
likely-violated when ANY judged hunk falls below threshold, satisfied
only when EVERY judged hunk is at or above it. A pr/report-kind clause (which
was already one verdict, not one per hunk) is unaffected. The full per-hunk
detail — every judged hunk, not just the worst and the further violations
shown — always stays available: nested under each clause's hunks field in
--json, or printed inline with --detail in the text report.
## hunk
- [likely violated] hunks-1
hunks judged: 12, below threshold: 12
worst hunk: src/invented/module1.ts:1-3 (20%)
further violating hunks:
- src/invented/module2.ts:1-3 (20%)
- src/invented/module3.ts:1-3 (20%)
- src/invented/module4.ts:1-3 (20%)
--json with --max-hunk-calls 1 in effect (2 hunk-kind clauses against
a 12-hunk diff — a budget smaller than the clause count, the per-clause floor
case). hunks-1 (the first [state:hunk] clause in the reference document)
got the single available hunk, so it carries hunksJudged/hunksTotal (the
"1 of 12" marker, present only because this clause was actually cut short)
and its usual hunks/worst detail. hunks-2 got none of the budget — it
is not-evaluated with a reason naming why, not dropped from clauses[].
The pr-kind change-limits-1 clause is unaffected by the hunk budget at all,
and has no hunks field (flow 326 nit: a pr/report-kind clause is never
scored per-hunk):
{
"ref": "docs/pipeline-contribution-policy.md",
"target": { "kind": "pr", "label": "PR #998 — ..." },
"threshold": 0.5,
"clauses": [
{
"clause_id": "change-limits-1",
"state_kind": "pr",
"status": "satisfied",
"probability": 0.91,
"evidence": ["..."]
},
{
"clause_id": "hunks-1",
"state_kind": "hunk",
"status": "likely-violated",
"evidence": [],
"hunksJudged": 1,
"hunksBelowThreshold": 1,
"hunksTotal": 12,
"worst": { "location": { "path": "src/invented/module1.ts", "startLine": 1, "endLine": 3 }, "probability": 0.2 },
"hunks": [{ "status": "likely-violated", "probability": 0.2, "location": { "path": "src/invented/module1.ts", "startLine": 1, "endLine": 3 }, "evidence": [] }]
},
{
"clause_id": "hunks-2",
"state_kind": "hunk",
"status": "not-evaluated",
"reason": "skipped by --max-hunk-calls (0 of 12 hunks judged)",
"evidence": []
}
],
"hunkBudget": { "maxHunkCalls": 1, "totalHunks": 12, "hunksJudged": 1, "hunksSkipped": 11, "truncatedClauses": ["hunks-1", "hunks-2"] },
"usage": { "jevCalls": 2 }
}
Opt-in, and named as a privacy decision. Disabled by default. The
document's clauses, PR text, report and diff are sent to Jev after secret
redaction, and nothing leaves the machine unless review.jev.conform is on.
A project turns it on with review.jev.conform: true in
.metaproject/tasks.config.json. Sending the reference document's own clause
text to Jev is the feature, not a leak — checking a clause requires the model
to read it — and it is opt-in the same way the rest of this command is: only
SECRETS inside that text (and inside the PR/report/diff state) are stripped
first, by the same redactSensitiveText pass every other piece of state sent
to Jev already gets. With the setting off, or with no OpenRouter credential,
the command refuses before any read and makes no network call.
A checkable clause whose kind has no target this run is not evaluated,
never silently dropped — the same discipline not-checkable clauses get.
This also covers a hunk-kind clause --max-hunk-calls left with 0 judged
hunks (flow 326, AC3): it is not-evaluated with a reason naming the budget
("skipped by --max-hunk-calls (0 of N hunks judged)"), distinct from a
target that supplied no state for that kind at all. The /conform TUI runs
the identical budget (fixed at the default --max-hunk-calls, not
configurable there) and shows the same "hunk budget:" line and per-clause
"judged on K of N" marker.
Honest limits. This mode is the least mechanical use of Jev in this
repository: deciding whether a PR body names an out-of-scope list is closer
to judgement than a styling checklist bullet. Measured against real pull
requests and a real review package: Jev's noul scores hedge low rather than
confidently discriminating (no score reached 0.8+ probability across a
115-question live check, and only a handful crossed the 0.5 default
threshold on ordinary small maintenance PRs) — read likely-violated as
"worth a human glance," not a confirmed finding. See flow 308's own journal
(.metaproject/flows/308-*/journal.md) for the full measurement.
Opt-in, and named as a privacy decision. Disabled by default. A project
enables it with review.jev.ci_triage: true in
.metaproject/tasks.config.json — the log excerpt leaves the machine to
OpenRouter/TypeSafe, so the mere presence of an OPENROUTER_API_KEY never
implies consent. With the setting off, or with no OpenRouter credential
(OPENROUTER_API_KEY, or a saved openrouterKey), the command refuses and
makes no network call — neither the CI read port nor the Jev client is
ever reached in that state. (--eval without --live needs neither: nothing
it reads ever leaves the machine.)
Advisory only, by construction. The CI read port (src/review/ci-port.ts)
has exactly six read methods (three from flow 306, three flow 307 added for
signals) and no write method at all — there is no rerun, status-check, or
merge call anywhere on this path to call. The printed verdict is always
labelled ADVISORY ONLY.
Per-option probability, not a single choice answer. OpenRouter's own
TypeSafe SDK guide documents no shape for a choice answer that carries a
probability per option — only the chosen option. So this command asks one
noul question per bucket (flaky, infra, real-regression) instead of
one choice question, and reports the three probabilities together.
Deterministic signals, computed before Jev is asked (flow 307, AC1/AC2).
For every failed job, through CiPort and git show only — never a write:
- rerun — did the SAME job pass on an EARLIER attempt of this SAME run (a "re-run failed jobs"/"re-run all jobs" rerun)?
- history — of a bounded window of this workflow's other recent runs (last 50, at most 8 actually inspected, at most 5 logs actually fetched), how many failed the SAME test on ANOTHER branch, and how many of those later passed on that branch?
- diff proximity — does this commit change the failing test file itself,
a file in its own directory, or (best-effort, via
git show <sha>:<path>) a file the test imports? - log markers — timeout/runner-lost/network/OOM/dependency-install- corruption phrases in the log text itself.
- same head, later — did a LATER run of the exact same commit (a manual
re-trigger, not a new push) have THIS SAME JOB conclude success? A later
run that is merely green AT RUN LEVEL does not prove this specific job
passed (the run could have skipped, dropped, or renamed it, or another job
in it could be the one still failing) — this is verified by reading that
later run's own job list through
CiPort.runInfoand requiring a job of the same name to have concludedsuccess. When a later run is green at run level but this job cannot be confirmed there, no deterministic override is applied; the run is still mentioned as an advisory-only evidence line.
These are placed in Jev's state as a labelled block above the log excerpt
(redacted the same as everything else, and counted against the same 64k
budget), and the questions are rewritten to point at that block explicitly.
Printed evidence lines mirror state's signals block exactly.
Deterministic override (AC8). When the rerun or same-head signal alone
answers the question — the same job/test passed on another attempt, or a
later run of the same commit passed — the verdict says DETERMINISTIC and
names the reason, and Jev's probabilities are still shown beside it, not
replaced: the signals decide top, not the numbers under it.
A known signal gap, found while building the evaluation set: the
cross-branch history signal matches candidate runs by JOB NAME. The SAME test
failing under a DIFFERENT job-matrix leg (e.g. opentui native (darwin-x64)
vs. opentui native (linux-x64) — the real schedules-sidebar.test.ts AC11
flake did exactly this across two of this evaluation set's cases) is not
picked up by that signal today. Diff proximity is also a heuristic that can
point the wrong way on a PR whose OWN diff is the flakiness fix landing in
the same commit as the failure (also observed in the evaluation set).
Another known signal gap, found in post-merge review: the same-head job-level check above matches the later run's job by NAME too, and a later run can have more than one job sharing that name (a matrix leg, a reused workflow). When that happens the deterministic override is withheld — which one of them is "this job" cannot be told apart from the name alone — and the run is only mentioned as an advisory-only evidence line, the same degrade path a missing, skipped, or unreadable job already gets.
A third known signal gap (flow 326, AC6): pagination can hide the triaged
run's own entry from the same-head list it is compared against.
runsForHeadSha is bounded/paginated (-L 10 in the live gh run list
adapter) and can come back without an entry for the run actually being
triaged at all — just missing from that one page. The old comparison read
that as "every success in the list is later than this run", which could
credit a run that in fact ran BEFORE the triaged one (only missing from this
particular page) as same-head rerun evidence. With no baseline timestamp for
the triaged run, "later" cannot be answered honestly, so the whole same-head
signal is skipped rather than guessed whenever this happens — no override,
no advisory evidence line from a later-passed run, only a dedicated
same head: other run(s) ... were found, but this run's own entry was not
among them (pagination) — cannot tell whether they are later, so no override
applied. line. This is a gap in COVERAGE, not correctness: a triaged run
that genuinely did have a later, verified same-head pass can go unrecognized
if pagination happens to drop its own entry from the page fetched, exactly
the same shape the job-name-ambiguity gap above has (the signal degrades to
"no evidence" rather than to a wrong answer).
Measured accuracy (flow 307, AC6) — honestly, whatever it is. A live run
of --eval against the real runs of the eight cases in
src/commands/fixtures/ci-triage-eval/ (gh/Jev, not fixtures; 2026-09-25):
| accuracy | cost | |
|---|---|---|
| before (flow 306: log only) | 4/8 = 50% | $0.00077 |
| after (flow 307: log + signals) | 4/8 = 50% | $0.00083 |
Raw top-1 accuracy did not move. Signals fixed one case (a thin log excerpt
that Jev alone read as infra; the diff-proximity and cross-branch-history
evidence correctly pointed at real-regression) and broke a different one
(a job whose log said timed out after 5000ms — about as clear an infra/
flaky marker as a log gets — that Jev alone got right and got wrong once
the signals block and its more pointed question wording were added). The two
runs of the identical underlying regression in this set (same test, same
root cause, different CI runs) also got different verdicts from Jev BEFORE
any signal was added, which is a reminder that the model's own answer is not
perfectly stable run-to-run holding the failure fixed. This classifier
remains a hint, not a diagnosis — treat a non-DETERMINISTIC verdict
accordingly regardless of --top. See the flow 307 journal
(.metaproject/flows/307-*/journal.md) for the full per-case breakdown and
the evidence behind each of the eight labels.
review jev-rules¶
Flow 330. An ADDITIONAL orchestrator reviewer — never replacing any other —
that checks every changed hunk against every applicable clause of every
discovered project rule, scored by Jev's noul "does this hunk VIOLATE this
clause?" and written up entirely by keryx: Jev supplies only a probability,
never prose. Unlike review conform, this is dispatched by
review-orchestrator itself (Wave B), as a CLI call rather than an LLM
sub-agent — keryx review reviewers --json marks it "engine": "jev".
keryx review jev-rules --scope scope.json --json
keryx review jev-rules --pr 712 --repo MrCipherSmith/keryx --rules rules/core --json
| Flag | Description |
|---|---|
--diff <ref> | --pr <n> | --scope <scope.json> |
Exactly one is required. --scope takes the whole keryx review scope --json document (the same file every other reviewer's dispatch reads) — the orchestrator's own path. --diff/--pr build the scope themselves via hunkRegionsFromDiff. |
--rules <paths> |
Comma-separated extra rule files/directories, checked in addition to auto-discovery (.metaproject/rules/**, rules/**, .claude/rules/**, the root CLAUDE.md (else AGENTS.md) without keryx's managed block, and any project-skill/installed gdskill whose name or metadata.category marks it a coding convention). The category filter below is bypassed only for an entry naming ONE FILE explicitly; an entry naming a DIRECTORY is walked and its files are filtered exactly like auto-discovery — naming a whole directory is not the same as naming one document. |
--max-calls <n> |
Caps how many (hunk, rule clause) pairs are scored, default 150. Hunks are ranked code, then tests, then docs, and the budget is allocated round-robin across hunks (see "Fair budget allocation" below) rather than draining on the first hunk in diff order; anything beyond the cap is reported dropped, never silently, and per-hunk coverage is reported under selection.hunkCoverage. |
--threshold <0..1> |
Below this Jev probability a pair is not reported. Default 0.5. |
--repo <owner/repo> |
Passed to the live gh adapter for --pr. |
--model <jev-1.13\|jev-latest> |
Overrides the default Jev model — used for both clause tagging and violation scoring. |
--fixtures <dir> |
Answers the pr-kind read (pr.json) and every Jev call (jev-responses.json, a JSON array consumed in call order — tagging calls first, then violation calls) from files on disk — no real gh call, no real network. |
--json |
Prints a REVIEW_RESULT-shaped object (status, reviewer: "review-jev-rules", summary, findings, stats, plus tokens/selection (including hunkCoverage)/ruleSources (with category)/excludedSources/droppedClauses/droppedPlaceholderClauses) conforming to reviewer-finding.schema.json. |
Finding synthesis is deterministic. problem quotes the violated clause;
impact is the rule's own stated rationale (its first clause under a
heading naming rationale/why/purpose/reason) or a fixed template when the
rule states none; suggested_fix names the clause to bring the hunk in line
with; evidence carries every hunk location and Jev's probability. Severity
is capped at minor unless the clause itself declares a higher one
([severity: major], trailing on the clause text) — the same marker syntax
review conform's [state:pr] already uses. One finding per (rule clause,
file), deduped across every hunk of that file with a hunk list, never one
finding per hunk.
Four precision/cost filters run before any hunk is scored:
--rulesbypass, scoped to one file.discoverRuleSources(src/commands/review-jev-rules.ts) only lets a--rulesentry that names ONE FILE explicitly skip the category filter below. A--rulesDIRECTORY is walked and every file it contains is filtered exactly like.metaproject/rules/**auto-discovery — a live re-measurement found the opposite bug: passing--rules .metaproject/rules(the whole 41-doc corpus, as a directory) bypassed the category filter for every file in it, unconditionally.- Rule-source category filter. Before clause extraction runs at all,
each discovered source (auto-discovered, or found by walking a
--rulesdirectory — never a--rulesentry naming that one file explicitly) is classifiedcode/process/docs— explicit frontmatter first (applies_to: code|process|docsat the frontmatter's top level, ormetadata.category), else a documented filename/title heuristic (PROCESS_RULE_HEURISTIC_TERMSinsrc/review/jev-rules.ts:commit,git,tdd,workflow,definition-of-done,documentation,requirements,plan,prompting,subagent,skill,jobs,orchestrat,review-process,release). Aprocess/docssource is excluded before it ever reaches tagging or scoring — reported with its reason underexcludedSourcesin--json(and counted in the summary line). - Placeholder/template clause drop.
isPlaceholderClauseText(src/review/jev-rules.ts, a pure function) drops a clause extracted from authoring scaffolding a rule author never filled in — a checklist item still carrying a<...>placeholder ([x] <criterion 1> — verified by <test>), text dominated by<...>placeholders, or a bare code-fence line — BEFORE it is offered a tagging call. Dropped clauses are reported underdroppedPlaceholderClausesin--jsonand counted in the summary line. - Clause-kind AND file-kind gating. Every rule doc's remaining clauses
are tagged
state_kind: "pr" | "report" | "hunk"andcheckable— REUSINGreview conform's own tagging (applyClauseTags/buildClauseTagQuestions/clauseTagFromChoice), an explicit[state:hunk]/[not-checkable: ...]marker on the clause text when the rule author wrote one, else a single Jevchoicecall per doc's untagged clauses, cached by the doc's content hash at.metaproject/data/review-jev-rules/clause-tags.json(a jev-rules- specific cache file, so areview conformrun and areview-jev-rulesrun never race on the same one). Only a clause taggedstate_kind: "hunk"andcheckableis ever paired against a hunk — a"pr"/"report"-kind or not-checkable clause is dropped and reported (count in the summary line, full(ruleId, clauseId, reason)list underdroppedClausesin--json). On top of that,clauseFileKindApplicabilitygates by file kind: a docs hunk (.md/.mdx/.txt) pairs only with adocs-categorised source or a clause explicitly tagged[docs-applicable]; a code source never pairs with a docs hunk, and vice versa — the fix for a code-convention clause matching acli-reference.mdhunk with no declared path restriction. Tagging is one-time per doc content: the usage summary counts it separately,tagging: N call(s) (cached next run), fromviolation: N call(s)(thenoulscoring calls).
Fair budget allocation. Hunks are ranked code (non-docs, non-test) first,
then test files, then docs files — hunkPriorityRank in
src/review/jev-rules.ts — with the diff's own order breaking ties within a
rank. selectRuleHunkPairs then spends --max-calls round-robin across
hunks: K = max(1, floor(maxCalls / hunks-with-a-pair)) pairs per hunk per
round, computed once, so every hunk in the rotation gets at least one round
while the budget allows one K-sized slice per hunk; a hunk whose pairs run
out simply drops from the rotation and the freed capacity keeps circulating.
This is the direct fix for a measured bug: on a live PR, the entire default
150-call budget landed on a single docs hunk in cli-reference.md
(processed first in diff order under the old first-come allocation), and not
one code hunk was ever scored. selection.hunkCoverage in --json reports
one {path, startLine, endLine, applicablePairs, selectedPairs} per hunk
that had something to check, and selection.hunksReached/
hunksNeverReached (also named in plain text in summary) say how many of
those hunks the budget actually reached.
These fixes are the answer to a measured problem: a live check of this
reviewer against a merged PR of this repository, using this repository's own
41-doc .metaproject/rules/** corpus (none of which declares
metadata.paths/stack_requires), hand-labelled ~1/10 findings correct —
several of the false positives were exactly a process/agent-behaviour rule
(commit-message formatting, TDD workflow, an agent's own prompting standard)
paired against a code hunk it was never meant to describe. A follow-up
re-measurement then found the --rules directory bypass and the
first-hunk-drains-the-budget allocation described above. See
.metaproject/flows/330-*/journal.md for the full before/after numbers.
Opt-in, and named as a privacy decision. Disabled by default. A project
enables it with review.jev.rules: true in .metaproject/tasks.config.json.
Every hunk, every rule-clause, and every clause sent to a tagging call is
redacted first (src/security/service.ts), the same floor review
conform/review ci-triage already apply. With the setting off, or with no
OpenRouter credential, the command refuses before any read and makes no
network call.
Two caches, both gitignored and mode 0600 under
.metaproject/data/review-jev-rules/: violation-cache.json, keyed on the
clause text and hunk location (a re-run over unchanged hunks and unchanged
rules costs zero additional Jev calls), and clause-tags.json, keyed on the
rule doc's own content hash (a re-run against a DIFFERENT diff but the SAME
rule corpus costs zero additional tagging calls, even though the hunks —
and so the violation cache — miss).
review jev-edit-guard¶
Flow 343. The Jev EDIT GUARD — a Claude Code PostToolUse hook that catches
a rule violation the moment an Edit/Write/MultiEdit makes it, rather
than at the next review round. It reuses review jev-rules's own
computeJevRulesResult (discovery, clause tagging with its cache, pair
selection, batching, threshold-based finding synthesis) unchanged — the only
new work is: diffing the ONE file the tool call just touched against HEAD,
running that one changed region through it, and turning the result into the
hook's own feedback text.
keryx review jev-edit-guard install # merge-safe .claude/settings.local.json write
keryx review jev-edit-guard status [--json] # on/off, threshold, today's calls/flags/cost
keryx review jev-edit-guard uninstall # removes only this hook's entry
keryx review jev-edit-guard --hook claude # what the installed hook itself runs; reads the PostToolUse payload from stdin
| Flag/subcommand | Description |
|---|---|
install |
Merge-safe, idempotent (same primitives keryx security hooks install uses — a standalone SurfaceAdapter, src/integrations/jev-edit-guard-surface.ts): writes a PostToolUse group matching Edit\|Write\|MultiEdit into the Claude settings file the other keryx hooks use (.claude/settings.local.json by default, .claude/settings.json under shared scope), preserving every other hook entry. Running it twice never duplicates the group. |
uninstall |
Removes only the entry this installer wrote (tagged with its own sentinel), leaving every other hook untouched. |
status [--json] |
Whether review.jev.edit_guard is on, the effective threshold and per-run call cap, and today's Jev calls/flags/cost from .metaproject/data/jev/edit-guard.jsonl. |
--hook claude |
What the installed hook actually invokes. Reads the PostToolUse payload from stdin, diffs the named file against HEAD, and prints a {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "..."}} line ONLY when something is flagged — otherwise it prints nothing. Any other --hook runtime is a no-op (no codec exists yet), exiting 0. |
Opt-in, same shape as review jev-rules: review.jev.edit_guard: true in
.metaproject/tasks.config.json. Threshold: review.jev.edit_guard_threshold
(default 0.5). Per-run budget/cost cap: review.jev.edit_guard_max_calls
(default 24 — a single edit is usually 1-3 hunks, so this doubles as the
feature's own cost ceiling). With the setting off, or with no Jev/OpenRouter
credential, the hook makes no network call and stays silent.
Fails open, always, no exceptions. Disabled, no credential, malformed
hook JSON, an unsupported tool, no changed region against HEAD, a Jev
error, or the hard ~4 second wall-clock timeout on the whole invocation
(gate + diff + every Jev call, enforced by aborting the in-flight request,
not merely by racing a promise) — every one of these prints nothing and
exits 0. Only a real finding at or above threshold ever produces hook
output, capped at 5 lines of feedback per invocation. Every invocation is
logged to .metaproject/data/jev/edit-guard.jsonl (mode 0600,
gitignored): timestamp, file, tool, status, latency, Jev calls, cost, and
every flag (rule, clause, line, probability) — status/the TUI's
/editguard modal read this same log.
Measured on a real project (10 tasks × 2 runs, a large production
React/MobX frontend): at threshold 0.5, real rule violations reaching the
first review round fell from 27 to 10 (−63%) and review rounds from 31 to
24, at the same total cost (Jev itself cost $0.04 for the 40 runs); the
agent acted on 79% of the flags. At threshold 0.2 the guard flagged almost
every edit, the agent learned to ignore it, and it had no measurable
effect — this is why the default is 0.5 and not lower: precision matters
more than recall for a hook an agent reads on every edit.
review ci-triage --eval¶
keryx review ci-triage --eval src/commands/fixtures/ci-triage-eval/manifest.json
keryx review ci-triage --eval src/commands/fixtures/ci-triage-eval/manifest.json --live --repo MrCipherSmith/keryx
The committed evaluation set
(src/commands/fixtures/ci-triage-eval/manifest.json + cases/) is eight
labelled failed CI jobs from this repository's own history (four real
regressions, three flaky, one infra), each with the real run id, job name,
head sha and head branch, and a truth label checked against gh run view
--json jobs and the job's own --log-failed text before being recorded.
Log excerpts are authored to be representative rather than a byte-exact copy
(the same discipline src/commands/fixtures/ci-triage/ already follows); no
secret appears in any fixture file.
Offline (the default) replays every case from its own fixturesDir — a
canned jev-response.json, so before/after differ only where a case's
fixtures carry deterministic evidence (none of the eight do today: every real
fix in this set landed as a new commit, never an actual CI rerun, which is
itself an honest finding about this repository's CI history). --live
replays the SAME run/job pairs against the real gh and the real Jev
endpoint instead, and is where before and after can actually differ,
since the two passes ask Jev different questions.
review budget¶
The gate to run before dispatching reviewers, where stopping is still
possible. review ingest --spent can only record that a round went over; by then
the money is spent.
Exits 1 when spend has reached the ceiling, with a message naming the overage and asking the operator. Prints the dispatch waves, the queue size, and whether the concurrency cap is measured against a declared count of running subagents.
| Flag | Description |
|---|---|
--spent <usd> |
Spend so far. Omitted, the record reads not recorded — which is not under: staying inside the ceiling was never demonstrated. |
--ceiling <usd> |
Override the 3.00 USD default. |
--reviewers a,b |
The reviewer set to plan into waves. |
--parallel <n> |
Override the wave size (default 10, split by diff domain). With --full it can only lower it. |
--full |
A full review or --all: holds the wave to 10 whatever --parallel says. |
--outstanding <n> |
Subagents the main session still has running. The only thing that makes the cap mean anything beyond this dispatch. |
review bot and review metrics¶
Review a pull request with one reviewer turn plus one verifier turn per finding, and measure what that came to. See Review as a pull request bot.
keryx review bot run --pr 7 --repo acme/app
keryx review bot post --pr 7 --repo acme/app
keryx review bot post --pr 7 --repo acme/app --post
keryx review metrics --refresh
review bot runcalls the model and records the surviving findings as a managed review. Nothing is posted. A fork pull request is refused before any model call, and the diff is cut at--max-diff-byteswith the cut stated in the output.review bot postbuilds one review (eventCOMMENT, pinned to the pull request head). It is a dry run unless you pass--post, and it refuses a closed, merged or fork pull request, a--shathat is not the head, and a review made at an older commit.review metricsprints findings raised, acted on, dismissed by kind, answered and still open, precision (acted on over acted on plus dismissed as incorrect) and resolved-before-merge. A ratio with no data printsn/a.--refreshreads merge state from GitHub first.
review tier¶
Which model a dispatch is worth, computed — the model block the subagent
dispatch contract expects, printed ready to paste.
It sits beside review scope, review blast-radius and review budget for the
same reason those exist: all four are compute this mechanically, before
dispatching, instead of eyeballing it. The rule used to say an orchestrator
"calls decideDispatchModel" — but an orchestrator is an agent following prose
and cannot call a TypeScript function, so what actually happened was a model
reading a table of signals and doing the arithmetic in its head. This is the
entry point that removes that step.
| Flag | Description |
|---|---|
--scope <scope> |
The round's scope. blast-radius floors the tier at deep. |
--fix-attempt <n> |
1-based attempt at the same finding. >= 2 raises one tier. |
--forced-strategy-change |
A strategy change forced by hitting the loop cap. Floors at deep. |
--findings <n> |
Findings in scope for this dispatch. |
--diff-lines <n> |
Changed lines in the diff under review. <= 3 findings and <= 50 lines allow light. |
--verifier <method> |
execution, site-check or reasoning. The first two allow light: the evidence comes from running something. An unrecognised method is refused, not ignored. |
--security |
Any security finding in scope. Never below standard. |
--session-provider <id>, --session-model <id> |
The session's own provider/model. Omitted, they come from KERYX_SESSION_PROVIDER/KERYX_SESSION_MODEL; with neither, the block is adaptive. |
--from-shell-config |
Use the selection keryx shell persisted as the session. Off by default: it is keryx shell's last choice, not the caller's model. |
--catalog <file\|-> |
The candidate set, in the detectProviders() shape [{"name": …, "models": [ … ]}]. Omitted, providers are detected live. |
--json |
Print the {"model": …} block alone. |
Floors are applied after downgrades, so at least deep means at least: a
blast-radius round over a twelve-line diff is still a blast-radius round. The
output carries the ordered rule ids that produced the tier, so the answer can be
explained afterwards rather than re-derived.
No model name exists in this command, and there is no --tier flag. The
session's provider/model and the candidate set are both discovered at runtime;
src/gdskills/model-tier.ts places the tier relative to the session's own model
using size words in model names (mini, haiku, pro, opus, …), never a list
of models that exist. Accepting a hand-written tier would put the arithmetic
back in the caller's head, which is the defect.
A model is named only when discovery found a different one; otherwise the block
is adaptive, and exits 0. With no session named, an unrankable catalogue, a
session model carrying no size marker, or a tier that resolves to the session
model, the block carries inherit: true with the tier instead of
provider/model, and the host dispatches on its own model for that tier
(standard: the session model). Never a downgrade, never a failure — degrading
capability because discovery failed is the worst of the three outcomes. Detection
is skipped entirely when the session names neither provider nor model, because
ranking is refused without an anchor whatever the catalogue holds.
review loop¶
Loop detection, not counting. The round bound fires on attempt count alone, so an agent producing the identical failing output three times spends the whole budget before anything notices — a counter cannot tell "converging slowly" from "stuck".
Escalates (exit 1) when the same finding recurs in two rounds, or two consecutive rounds produce identical review output. Regardless of the remaining round budget, which it deliberately never reads: a detector handed the remaining budget is a detector that can be argued out of firing.
It reads real persisted state — the flow's review packages on disk, ordered by
manifest.createdAt, and tasks[].attempts.count from flow.json — never a
session's own memory. A resumed orchestrator's context starts at zero while the
real count does not.
A finding's identity is its dedupe_key, then its global_id, then a derived
key over reviewer, file, symbol, line and problem text. Never the display id:
F-001 denotes a different finding in every round, so a detector keyed on it
would fire on the second round of every flow whatever happened.
Verification — --verifications, --verification-mode¶
attach, start and ingest accept the output of the review-verifier skill
and merge it into the package. The merge can only delete. It cannot raise a
severity, add a finding, or change a finding's text: the merged record is built
from the finding as reported, and only the verdict is taken from the claim. A
claim that carries anything else is discarded whole and the attempt is recorded
in scope.md.
This replaced review-strict, which re-read consolidated findings and adjusted
their severity with no new evidence. That operation is measured to make accuracy
worse — GPT-4 on GSM8K falls 95.5 → 91.5 → 89.0 across self-correction rounds and
GPT-3.5 on CommonSenseQA falls 75.8 → 38.1 (Huang et al., ICLR 2024,
arXiv:2310.01798) — so it was removed rather than improved.
| Flag | Description |
|---|---|
--verifications <file\|-> |
The verifier's result: an array of claims, or {verifier, verifications}. |
--verification-mode <mode> |
off, annotate (default), or filter. |
--scope <scope.json> |
The whole --json output of keryx review scope, so the record carries what the pre-filter dropped and why. Handing over only its counts object is refused: eight integers carry no reason for any individual drop. |
--tokens-in <n>, --tokens-out <n> |
What the round actually used. Recorded on the package; absent means nobody reported, which is not zero. |
A finding points at the code it quotes¶
A finding does not report a line number. It reports quote — the code it is
about, copied out of file — and review ingest locates that quote at the
commit the round records, writing the line the match started on. line is
therefore output, and a number a reviewer puts there is overwritten.
The reason is the failure it replaces: nothing compared a reported line to
anything, so a number that had drifted travelled into findings.json, into the
report, and into the disposition somebody later recorded against it. On a fix
round the file has moved under the finding by construction, which is when the
reported number is least trustworthy and most acted upon.
Matching is exact first, then with whitespace collapsed, then it stops — nothing
fuzzy. Three outcomes, all recorded in locator:
| Outcome | What it means |
|---|---|
derived |
The quote was found; line is where, and reported_line keeps whatever the reviewer claimed. |
unlocatable |
It was not, and line is null rather than a number nobody checked. The reason says which: the file is absent at that commit, the quote does not appear, or it appears in more than one place. |
| absent | The finding carries no quote — one about the round rather than a site. Its line is reported-only, and says so by carrying no locator. |
A quote matching twice is unlocatable rather than anchored to the first hit:
choosing between them would be a guess wearing a line number. Files are read at
the round's recorded commit, contained to that tree with realpath on both
sides, and bounded in size; the quote is bounded in length. A round is a claim
about a commit, and locating it anywhere else answers a question nobody asked.
What the ingest fills in, and what it refuses¶
Two fields are supplied when a report omits them, because both are typing rather
than judgement: id, from the finding's position in the report, and problem,
from the finding's own title. Whatever was supplied is recorded in
manifest.repairs — the field, the finding, and where the value came from — so
a later reader can tell a statement the reviewer made from one carried across.
Everything else stays refused. class_scope is an enumeration somebody has to
perform, evidence is something somebody has to have run, a disposition is an
outcome somebody has to have observed. A repair is accepted only if it
introduces no property the contract does not define, removes none, and preserves
the number of findings — so widening it fails a test rather than passing a
review.
Each claim is {finding, verdict, method, evidence, verifier} and nothing else.
verdict is confirmed, refuted or unverifiable; method is execution,
site-check or reasoning. Rules enforced in code, not by instruction:
- A verdict reached by reasoning alone is capped at
unverifiable. It can never beconfirmed, and it can never berefutedeither —refutedis the only verdict that removes a finding, and giving it to the method that produces no new evidence would reinstate the pass that was just removed. - A finding is never verified by the reviewer that raised it. A claim whose
verifiernames the finding'srevieweris discarded. The two names are compared after normalising case, surrounding whitespace,_/-and a trailing(model)annotation, soreview-logic,Review-Logicandreview-logic (sonnet)are all the same actor. The comparison deliberately over-matches: a refused claim costs a verdict, a missed self-verification costs the finding. - Two claims for one finding cancel only when they disagree, because there the outcome would otherwise be decided by claim order. Two verifiers reaching the same verdict record it, naming both and carrying both pieces of evidence — each claim was already admitted on its own evidence, so this is not voting.
- Every rejection retains the finding. A malformed, anonymous, disagreeing or mutation-carrying claim can cost a verdict, never a finding.
Modes:
| Mode | Effect |
|---|---|
off |
Nothing is verified. Supplied claims are refused rather than silently ignored. |
annotate |
Default. Verdicts are recorded on the findings; nothing is removed. A refuted finding is still reported, marked refuted. |
filter |
An applied refuted verdict removes the finding from the reported set and records it as dismissed-incorrect, carrying the verification evidence. |
annotate is the default so the drop rate is measured for a release before it
costs a real finding.
Every package's scope.md carries the stage counts — dropped by the
pre-filter, refuted by the verifier, retained — and the same numbers are printed
on creation. Without --scope, a ## Pre-filter scope block already in the
package is carried forward verbatim; with neither, the pre-filter half reads
not recorded rather than 0, because "dropped nothing" and "never ran" are
different facts. State results as these counts and never as a precision
improvement; the pipeline has no precision baseline to improve on.
filter_stats — the machine-readable copy¶
scope.md is prose. manifest.json carries the same numbers as filter_stats,
written by the code that does the filtering rather than re-derived from the
markdown, so a claim about what a round dropped can be checked afterwards by
something other than a person reading a file.
"filter_stats": {
"schema_version": 1,
"total": 5,
"dropped_prefilter": 2,
"dropped_low_confidence": null,
"dropped_refuted": 1,
"dropped_scope_b": null,
"dropped_findings_cap": 1,
"retained": 3,
"dismissed_by_round": null,
"by_reason": { "prefilter:lockfile": 1, "prefilter:generated": 1, "refuted:verifier-refuted": 1 },
"not_measured": [{ "stage": "low_confidence", "reason": "…" }, { "stage": "scope_b", "reason": "…" }]
}
null always means the stage did not run. It never means zero. Every null
count has a matching row in not_measured giving the reason, and the two are
written together — there is no path that omits one. dropped_low_confidence is
null on every record: confidence is recorded on each finding and no stage in
this pipeline filters on it, so the field is carried unmeasured rather than
reported as a threshold that never fired.
dropped_prefilter counts diff material — whole files and change blocks
removed before any reviewer read them. Every other count is findings, and only
the finding counts are summed: total minus the measured finding-stage drops
must equal retained.
keryx review status reads it back off disk and refuses — exit 1 — when the
arithmetic does not hold or when a null count has no stated reason. A package
written before filter_stats existed carries none; that is reported and exits 0.
Recording what became of a finding — --refuted, review complete¶
A review package that records only what a round reported keeps the survivors of an unlogged triage, and precision measured over survivors is 100% whatever the reviewers actually got right. Two commands write the other half.
| Flag | Command | Description |
|---|---|---|
--refuted <file\|-> |
ingest |
Findings this round raised and then dismissed, in the same finding shape, each carrying disposition: {state, evidence} with a dismissed-* state. acted-on and unknown are refused on this channel. |
--finding <id> |
complete |
The finding a disposition is about, by global_id or display id. Repeatable: each --finding opens a new record. |
--disposition <state> |
complete |
unknown, acted-on, dismissed-incorrect, dismissed-wont-fix, dismissed-out-of-scope, dismissed-deprioritised. |
--evidence <ref> |
complete |
Where the outcome is written down: the commit, the test, the decision. Required for every state except unknown. |
Only acted-on and dismissed-incorrect say anything about whether a reviewer
was right; the three other dismissals say the finding was correct and not
worth doing now. They are separate states rather than one dismissed bucket
because collapsing them makes a dismissal rate unreadable.
A recorded state and its citation cannot be overwritten by a later close —
record a correction as a new round. Closing with no disposition flags is allowed
and leaves every finding reading unknown, which means "nobody wrote down what
happened", never "the finding was correct".
review complete also prints what the round cost against what survived it:
This is the first moment both halves are known, which is why it is printed here.
A round nobody reported a cost for prints cost: not recorded and says that is
not zero. A round that retained nothing prints the bill rather than a
per-finding figure — an infinity dressed as a metric is worse than the plain
fact. And a per-finding figure that rounds down to nothing prints < 1 token
rather than 0, because in these records a zero means somebody measured one.
The learning note — .metaproject/memory/review-notes/¶
A finding recorded as dismissed-incorrect — and only that state — writes a
review-note memory entry naming the finding, the reviewer, why it was
dismissed, and the round and commit it came from. Both writers do it: ingest,
when a refuted verdict is applied in filter mode, and complete, when a
person records the disposition. The path is
.metaproject/memory/review-notes/<review-id>__<finding-id>.md, so re-running
either command overwrites its own note rather than accumulating one per run. The
entry is written Status: draft; only accepted entries influence skills.
A note is written only when the dismissal is attested. The record must carry
either a named human decision in the disposition evidence (human: <who>,
decided-by: <who>, and the other forms the completion gate accepts) or an
independent verifier's refuted verdict with a method and evidence. An
unattested dismissed-incorrect is still recorded in findings.json and writes
no note; the command prints review-note NOT written for <id> with the reason.
The orchestrator does not get to file a finding as its own error, teach a skill
from it, and move on with nobody having looked.
bun run baseline:review-precision recomputes the figure from the packages on
disk, joining on global_id.
security¶
Policy-based scanning, redaction, guardrails, and audit reports for agent
input/output and .metaproject/ artifacts. The engine is deterministic (rule +
entropy detectors, no model backend) and local-first: config lives at
.metaproject/security.config.json, data under .metaproject/data/security/,
and the local-only HMAC key under data/security/raw/ (gitignored). This is
Phase 1+2+3 of the spec — the engine, the CLI below, and the write-seam
integrations (an advisory-by-default guard at memory ingest, wiki collect,
test run, gdctx, and flow complete) are shipped. mode: "gateway"
already blocks exactly like enforced/ci — the write seam, the flow
completion gate, and the exit code of scan, report, check-input, and
check-output — it is not a report-only mode. Model/API backends and
gateway's own Phase-4 proxy behaviour are not implemented.
keryx security status
keryx security scan <path> [--json] [--source <kind>]
keryx security scan-mcp <manifest.json|dir> [--json] [--pin <manifest>] [--strict]
keryx security audit-harness [path] [--json] [--ci] [--fix-proposals] [--baseline <file>] [--severity-floor <level>]
keryx security audit-harness apply --proposal <id> [path]
keryx security audit-harness baseline add --finding <id> --justification <text> [--expires YYYY-MM-DD] [--author <name>] [--reseal] [--json]
keryx security impact-evidence status [--json]
keryx security impact-evidence test <file...> [--json]
keryx security impact-evidence hook [--runtime claude] [--profile <name>]
keryx security check-input [--source <kind>] [--file <path>] [--json]
keryx security check-output [--target <kind>] [--file <path>] [--json]
keryx security redact <path> [--out <path>]
keryx security report [--since <ref>] [--json]
keryx security policy validate
keryx security incidents [--limit <n>]
keryx security hooks install|uninstall --runtime <claude|cursor|windsurf|generic-mcp|all>
keryx security eval [--corpus <name|all>] [--with-model] [--json]
| Subcommand | Flags / args | Description |
|---|---|---|
status |
— | Print the effective config: mode, raw-retention, gate (failOn + minConfidence), config-checksum state, and each policy with its action. |
scan <path> |
<path>, --json, --source <kind> |
Scan a file, resolve findings into a decision, and write committable artifacts (data/security/artifacts/latest.{md,json}). Prints the gate, action, and findings (or raw JSON with --json). |
scan-mcp <manifest\|dir> |
--json, --pin <manifest>, --strict |
Scan one MCP tool manifest (or every *.json under a directory, recursively) for MCP threats. Findings are leak-safe (category + policy id only). --pin records a rug-pull baseline instead of scanning; --strict exits 1 when any threat is found, or when the scan could not read a manifest or the pinned baseline (coverage: incomplete). Pure and network-free. |
audit-harness [path] |
<path>, --json, --ci, --fix-proposals, --baseline <file>, --severity-floor <level> |
Read-only sweep of harness-configuration surfaces, computing a security score over harness fixtures and vulnerabilities. Outputs a summary report with surface coverage, findings by severity, and a pass/fail gate (score formula: 100 - (25*critical + 10*high + 4*medium + 1*low), grades A 90–100 / B 75–89 / C 60–74 / D 40–59 / F <40, any critical caps at C). --ci sets process exit code from the gate; --fix-proposals includes fix recommendations (never writes anything); --baseline <file> uses a project suppression file .metaproject/security-audit-baseline.json with required justification and checksum; --severity-floor <level> filters findings below that severity. A suppression's expiresAt (YYYY-MM-DD) is inclusive THROUGH the end of that day in UTC — an entry expiring today still suppresses today, and stops the instant the next day begins. |
audit-harness apply |
--proposal <id>, [path] |
Apply a specific fix proposal by id, against [path] (defaults to the project root). Fix proposals never self-apply; they are generated by audit-harness and applied by the operator. Refuses a path that does not already exist as a directory rather than creating it. |
audit-harness baseline add |
--finding <id>, --justification <text>, --expires YYYY-MM-DD (opt), --author <name> (opt), --reseal (opt), --json (opt) |
Record a suppression for a specific finding with required justification and optional expiration date (inclusive through the end of that day, UTC — see above). Suppressed findings are excluded from scoring and the gate. Adding to a baseline whose checksum does not match its contents (tampered or unreadable) is refused unless --reseal is passed explicitly; a reseal backs up the pre-reseal file to <file>.bak-<timestamp> before overwriting it, and reports (on stdout, or in the --json object) which finding ids from the old file were carried over into the new one vs discarded (lost because the old file could not be read at all). |
impact-evidence status |
--json |
Print the effective impact-evidence config (enabled, strict, exemptGlobs, dampenAfter), declared state, config-checksum match, kill-switch status (impactEvidence.enabled and KERYX_DISABLE_IMPACT_GATE both logged), host delivery readiness per adapter, and recent log records. --json for the machine-readable form. |
impact-evidence test |
<file...>, --json |
Dry-run computation of impact evidence for specified files. Writes nothing — no session state, no log — unlike hook, which is the live path. --json for machine-readable output. |
impact-evidence hook |
--runtime claude (opt), --profile <name> (opt) |
Live hook entry for pre-tool-context injection at the agent runtime level. --runtime (defaults to claude); --profile accepts monitored-trusted-local, read-only-review, or unattended-untrusted (defaults to monitored-trusted-local). Reads and processes JSON from stdin; outputs decision JSON to stdout. |
check-input |
--source <kind>, --file <path>, --json |
Evaluate incoming content (defaults source untrusted-external). Reads from --file or stdin. Prints the decision. |
check-output |
--target <kind>, --file <path>, --json |
Evaluate outgoing/generated content (defaults source generated, target unknown). Reads from --file or stdin. Prints the decision and, when applicable, the redacted preview. |
redact <path> |
<path>, --out <path> |
Apply fixed-width masks to detected sensitive spans. Writes to --out, else prints the redacted content to stdout. Reads from the path or stdin. |
report |
--since <ref>, --json |
Aggregate the latest scan artifact (never re-scans) into a summary: gate, mode, and finding counts by category. |
policy validate |
— | Validate the config against its schema and verify the config checksum. Exit 1 on schema errors or a checksum mismatch. |
incidents |
--limit <n> |
List the append-only incident trail (mode downgrades, disabled policies, checksum mismatches). Newest first; --limit caps the count. |
hooks install\|uninstall |
--runtime <id> |
Merge-safe install/uninstall of the agent security hooks for one or more runtimes. --runtime takes a runtime id, a comma-separated list, or all (defaults to claude); after install the rendered settings are validated. |
eval |
--corpus <name\|all>, --with-model, --json |
Run the labeled security corpora through the detectors and print a deterministic per-detector false-negative-rate report, exiting 1 when a detector breaches its committed threshold. --corpus defaults to all (or a comma list of corpus names); --with-model also exercises the opt-in model backends, warning once and falling back to the pure path when the model asset is absent. |
Runtime ids (--runtime): claude, cursor, windsurf, generic-mcp, or
all. Eval corpora (--corpus): injection, exfil, structured-pii,
secret, or all.
Source kinds (--source): trusted-project, trusted-user,
untrusted-external, tool-output, generated. Target kinds (--target):
model, memory, wiki, report, external, task, unknown.
Exit behavior. scan, check-input, check-output, and report all honor
the config mode through the same fold (exitCodeFor/reportExitCode,
src/commands/security.ts): in advisory mode (the default) they always
exit 0 after reporting; in enforced, ci, or gateway mode they
exit 1 on any gate other than pass — that is, on fail,
needs-approval, incomplete, or an unrecognized stored gate value.
report never re-scans; it reads the gate from the last stored scan artifact
and applies the identical fold. policy validate exits 1 on schema or
checksum failure. scan-mcp exits 1 only with --strict, when a threat is
found or when the scan could not read a manifest or the pinned baseline
(coverage: incomplete); eval exits 1 when any detector breaches its threshold; hooks exits
1 on an unknown runtime or a post-install validation error. status, redact,
and incidents do not gate. An unknown subcommand prints an error and exits 1.
serve-mcp¶
Run keryx as an MCP server, exposing read-only Metaproject services to an editor or agent.
This is the spelling keryx mcp serve used to have; that spelling still works
and prints a deprecation line.
keryx serve-mcp [--cwd <project-root>] # stdio JSON-RPC (default transport)
keryx serve-mcp --http [--cwd <root>] # HTTP/SSE, localhost only, opt-in
keryx serve-mcp --read-only [--cwd <root>] # hide every tool marked mutating
keryx serve-mcp --harness <id> [--cwd <root>] # bind a cross-harness memory identity (flow 313 / W4-AC6)
--cwd names the project root whose .metaproject workspace is exposed; it
defaults to the process working directory. --http requires
capabilities.http.enabled in the workspace. --read-only drops every tool
the registry marks mutating from tools/list, and a call to one by name
answers "Unknown or unavailable tool." --harness <id> (or the KERYX_HARNESS
env var, when --harness is absent) binds this server process's harness
identity once at launch — never per tool call — for memory.search filtering,
memory.handoff, and the Source-Harness stamped on memory.propose writes.
An unrecognised id refuses to start the server. keryx launches its own server this way
when it hands it to a foreign agent it drives over ACP (see
agents external and the
ACP client guide): that agent's MCP calls go straight
to this server and never pass keryx's permission bridge.
Under stdio, stdout is the JSON-RPC channel — diagnostics go to stderr, and so does the deprecation notice when the retired spelling is used.
See mcp for what is exposed and why it is opt-in.
integrate¶
Wire this project into an editor or agent, so that tool can reach keryx over MCP. It writes a client config belonging to that tool — nothing is installed.
Editors: cursor, claude, opencode, vscode, generic, all. Omitting the
editor means all, which is cursor,claude,opencode — vscode is opt-in and not
included. generic writes nothing and prints a pasteable snippet instead.
| editor | file written |
|---|---|
cursor |
.cursor/mcp.json |
claude |
.mcp.json |
opencode |
opencode.json |
vscode |
.vscode/mcp.json |
--dry-run prints the planned change and writes nothing. --remove deletes only
the managed keryx entry and leaves other servers in the file untouched.
These were keryx mcp install --runtime <editor> and keryx mcp uninstall
--runtime <editor>. Both still work and print a deprecation line.
integrations¶
Install, remove, and audit Keryx's own hooks and standing-instructions files
in ANOTHER coding agent/IDE's config (the harness adapter registry —
src/integrations/), and print or check the generated capability matrix.
This is a distinct namespace from keryx integrate (MCP client
configuration, above) and from keryx harness (Keryx's own agent runtime,
below). Full details, per-harness notes, and the legacy-alias table are in
integrations.md.
keryx integrations install --runtime <id>[,<id>...|all] [--surface <flag|id>]... [--dry-run] [--json]
keryx integrations uninstall --runtime <id>[,<id>...|all] [--surface <flag|id>]... [--dry-run] [--json]
keryx integrations doctor --runtime <id>[,<id>...|all] [--surface <flag|id>]... [--json]
keryx integrations matrix [--check] [--write] [--json] [--file <path>]
| Subcommand | Flags / args | Description |
|---|---|---|
install |
--runtime <id>, --surface <flag\|id>..., --dry-run, --json |
Resolve every surface for <id> (or only the named ones), apply each surface's merge in deterministic order, and record what it wrote to install-state. all targets every adapter with at least one surface; keryx-shell (no surfaces yet) is reported as unsupported rather than silently skipped. --dry-run reports what it would write and changes nothing; --json prints the structured result objects only. |
uninstall |
--runtime <id>, --surface <flag\|id>..., --dry-run, --json |
Remove only the sentinel-tagged entries Keryx itself installed, leaving every other entry (including the operator's own) untouched. Same --runtime/--surface/--dry-run/--json shape as install. The agents surface additionally never deletes a managed export file that was hand-edited since it was exported (its content-sha256 no longer matches) — it is kept, and reported as a warning; there is no --force override for this on uninstall. |
doctor |
--runtime <id>, --surface <flag\|id>..., --json |
Re-validate every surface recorded in install-state against the live settings file and report drift. --surface is additive here, unlike install/uninstall: it names an opt-in surface (e.g. agents) to doctor on top of the runtime's normal (non-opt-in) surfaces, even before it has ever been installed — it never narrows what gets checked. For a single explicit --runtime, an unknown selector errors the same way it does for install/uninstall. For a multi-runtime selection (--runtime all or a comma list) combined with --surface, a selector a given runtime does not declare is simply dropped for that runtime (that runtime is still fully doctored over its own surfaces, never skipped) rather than erroring it — same lenient matching install/uninstall use — and the command only errors if NO selected runtime declares any of the requested selectors, in which case every runtime is annotated <id> declares none of the requested surface(s): .... Exits 1 when any requested runtime's doctor result is not ok, or when every selected runtime declared none of the requested selectors. |
matrix |
--check, --write, --json, --file <path> |
With no flags, prints the generated capability matrix as a table (id, state, confidence, supported flags); --json prints the full document. --check regenerates and diffs against the checked-in artifact, exiting 1 on drift, writing nothing — this is what CI runs. --write regenerates and overwrites the artifact. --file overrides the artifact path (default docs/integrations/harness-capability-matrix.json). |
--runtime accepts a comma-separated list or all; --surface is
repeatable and also accepts a comma-separated value, matching either a
SurfaceFlag (block, prompt-gate, inject-context, instructions, …)
or a surface id (ctx-guard, orient, security-check-input,
security-check-output, …) — a flag can match more than one surface on the
same runtime, an id matches exactly one.
keryx ctx install-hook, keryx orient install-hook, and keryx security
hooks install are legacy aliases that already delegate to this same
installer core (see integrations.md).
mcp¶
Expose read-only Metaproject services (code graph, gdctx, security, flow status,
memory, health, testing, wiki, standard, SAC) over the
Model Context Protocol. A thin protocol adapter —
it defines no new module logic and routes every tool result through the security
redaction seam before transport. Opt-in: the module is off by default; enable it with
keryx init --mcp. Confirmed working live with Claude Code, Cursor, the codex CLI,
and the opencode CLI — codex and opencode both connect and call tools headlessly,
not just interactively.
keryx serve-mcp [--cwd <project-root>] # stdio JSON-RPC MCP server (default transport)
keryx serve-mcp --http [--cwd <project-root>] # isolated HTTP/SSE opt-in (localhost only)
keryx mcp # alias for `serve-mcp`
keryx integrate <cursor|claude|opencode|vscode|generic|all> [--dry-run]
keryx integrate --remove <cursor|claude|opencode|vscode|generic|all>
| Command | Flags / args | Description |
|---|---|---|
serve-mcp |
--http, --cwd <project-root>, --harness <id> |
Start the MCP server over stdio (the default). --cwd selects the project root whose .metaproject/ workspace is exposed; this is what makes editor/client launches independent from their process cwd. --http switches to the isolated localhost-only HTTP/SSE transport, which additionally requires http.enabled=true in the module's manifest entry. --harness <id> (or KERYX_HARNESS) binds a cross-harness memory identity once at launch; an unrecognised id refuses to start. Bare mcp is an alias for serve-mcp. |
integrate |
<cursor\|claude\|opencode\|vscode\|generic\|all> (comma-separated; default all), --dry-run |
Merge-safely wire this project into an editor/agent's MCP client config: cursor → .cursor/mcp.json, claude → .mcp.json, opencode → opencode.json (all project root), vscode → .vscode/mcp.json, generic prints a ready snippet and writes no file. all targets cursor + claude + opencode only — vscode is deliberately opt-in and must be named explicitly. cursor/claude add mcpServers.keryx = { command: "keryx", args: ["mcp","serve","--cwd","<absolute-project-root>"] }; opencode's shape differs — mcp.keryx = { type: "local", command: ["keryx","mcp","serve","--cwd","<absolute-project-root>"], enabled: true }; vscode's shape differs again — VS Code's native MCP config uses a top-level servers key (not mcpServers), each entry requiring "type": "stdio": servers.keryx = { type: "stdio", command: "keryx", args: ["mcp","serve","--cwd","<absolute-project-root>"] }. Every runtime's entry is marked with a managed sentinel, preserving existing servers/keys and staying idempotent. Also sets modules.mcp.enabled=true in metaproject.json and probes the optional SDK (printing bun add @modelcontextprotocol/sdk when absent — it never auto-installs or opens a network connection). --dry-run prints the planned change and writes nothing. |
integrate --remove |
<cursor\|claude\|opencode\|vscode\|generic\|all> (default all) |
Remove ONLY the managed keryx server (and its sentinel) from each runtime's client config, leaving other servers and user content intact. A no-op when nothing is installed. |
Retired spellings¶
The publisher surface was renamed so that mcp means one direction only
(D-04, docs/requirements/keryx-mcp-servers/decisions.md). Every retired
spelling still runs and prints one line naming its replacement — nothing was
removed, so existing scripts keep working:
| retired | current |
|---|---|
keryx mcp serve |
keryx serve-mcp |
keryx mcp install --runtime <editor> |
keryx integrate <editor> |
keryx mcp uninstall --runtime <editor> |
keryx integrate --remove <editor> |
codex CLI is not an integrate target here — its client config is a single GLOBAL
~/.codex/config.toml, not a project-local file, and it already ships its own safe,
native installer for it: run codex mcp add keryx -- keryx serve-mcp --cwd
<project-root> once (codex mcp remove keryx to undo). modules.mcp.enabled=true
still needs one keryx integrate <any> run, since codex's own installer
has no notion of the keryx manifest. Headless codex needs codex exec
--approve-for-me — plain codex exec silently cancels MCP tool calls without it.
init also offers to enable the MCP server interactively (default No); the
--mcp / --no-mcp flags set it non-interactively (opencode is also offered as a
runtime choice at that prompt). The default non-interactive init never enables MCP
nor writes a client config.
mcp (consumer) — connecting keryx TO other MCP servers¶
Everything above is keryx as an MCP server. This is the other direction:
third-party MCP servers that keryx shell connects to, so the model can use
their tools.
keryx mcp list [--json]
keryx mcp add <name> [-e KEY=value]… [--scope user|project] [--force] -- <command…>
keryx mcp add --transport http|sse <name> <url> [--header "K: V"]…
keryx mcp remove <name> [--scope user|project]
keryx mcp enable | disable <name>
keryx mcp trust | untrust <name>
keryx mcp doctor [name] [--json]
keryx mcp auth <name>
keryx mcp logout <name>
| Command | Description |
|---|---|
list |
Every configured server with its source tag (user, project) and whether it is disabled. --json adds the resolved entry, with env and headers reduced to set/unset — never the values. An empty list names the two files that were read, because "no servers" and "your config is somewhere keryx does not look" are different problems. Exit codes (flow 353, AC4): a problem in a FOREIGN config keryx merely reads for compatibility (Cursor's ~/.cursor/mcp.json, Claude Desktop's ~/.claude.json, a project's own .mcp.json, Grok's TOML config) prints under a warnings: block (a warnings array alongside problems in --json) and never blocks — exit 0. Only a problem in keryx's OWN native config (.keryx/mcp-servers.json, either scope) still exits 1. |
add |
Writes a native entry. The stdio form REQUIRES -- before the server's command: without it, keryx mcp add fs -- npx pkg --json could not tell whose --json that is. -e is repeatable. --scope user (default) writes mcp-servers.json in the keryx config dir, owner-only; --scope project writes <root>/.keryx/mcp-servers.json, which is meant to be committed. An existing name is refused unless --force. |
add --transport http\|sse |
A remote server by URL, with repeatable --header "Name: value". sse is an alias of http — streamable HTTP negotiates it, so it is not a separate transport. |
remove |
Deletes a native entry. With --scope omitted it resolves which file actually defines the name, and refuses when both do rather than guessing which one you meant. |
enable / disable |
A personal overlay in the keryx config dir, never an edit to the config file. Disabling a server your project committed produces no diff for your colleagues; enabling one the project disabled works for the same reason. |
trust / untrust |
Approve (or withdraw approval for) a PROJECT-scoped server. See "Project servers need approval" below. Prints the command it would launch before recording anything. |
doctor |
Config problems, a real connection attempt, the tool count, and every tool that had to be skipped with the reason why. --json for the machine-readable form. Exits non-zero when anything needs you. |
auth |
Runs a remote server's OAuth flow in your browser and stores the result owner-only. The only command that opens a browser — see "Servers that need a login" below. |
logout |
Forgets a stored credential. Needed because a revoked refresh token cannot be repaired by re-running auth, and because a server removed from the config leaves its credential behind. |
Config files.
| File | Role |
|---|---|
<keryx config dir>/mcp-servers.json |
User-global. Owner-only (0600) — env values are often tokens. |
<project>/.keryx/mcp-servers.json |
Project-scoped, meant to be committed. Wins over the user file for the same name (replace, not field-merge). |
<keryx config dir>/mcp-servers-disabled.json |
Your personal enable/disable overlay. Wins over both, in either direction. |
<keryx config dir>/mcp-credentials.json |
OAuth tokens from keryx mcp auth. Owner-only (0600), never committed, never printed. |
${VAR} and ${VAR:-default} expand in command, args, env, url and
headers at load time.
What the model sees. Two tools, search_tool and use_tool — not one
registered tool per MCP tool, however many servers you connect. The model
searches for a tool by description, then calls it by qualified name
(server__tool). The trade this buys is a tool surface whose cost does not
grow with your server list; the cost is that the model cannot see a tool it
has not searched for.
Project servers need approval. A server in <project>/.keryx/mcp-servers.json
is committed, which means it is a command someone else wrote the moment you
clone the repository. keryx will not start one until you have said so:
$ keryx mcp list
docs (project) (needs approval) stdio — npx -y some-docs-mcp
1 project server(s) are not started until approved — a committed config is code someone else wrote.
Read what it launches above, then: keryx mcp trust <name>
The approval is bound to the exact command, so a later commit that changes
what docs runs needs approving again. It is stored in your own config
directory, never in the repository. Your own keryx mcp add servers
(user scope) need none of this.
Remote servers and their credentials. A url server is dialled over
streamable HTTP and is otherwise identical to a local one: same catalog,
same search_tool/use_tool, same approval gate, same result cap, same
trust rule for project scope.
Credentials come from the environment, two ways:
keryx mcp add linear --transport http https://mcp.linear.app/mcp \
--header 'Authorization: Bearer ${LINEAR_TOKEN}'
# or, equivalently
keryx mcp add linear --transport http https://mcp.linear.app/mcp \
--header 'X-Whatever: v' # plus, in the config file: "bearer_token_env_var": "LINEAR_TOKEN"
If the variable is unset, keryx does not dial. It does not send
Bearer and let the server reject it — a hollow credential produces a 401
that reads as the server being broken, and on a server that treats an empty
bearer as anonymous it may be accepted as the wrong identity. Instead:
$ keryx mcp doctor linear
linear [user] http: needs_auth — header "Authorization" needs LINEAR_TOKEN, which is unset
unset: Authorization
An explicit Authorization header wins over bearer_token_env_var if you
somehow write both.
Servers that need a login: keryx mcp auth. A remote server that has no
header and no bearer_token_env_var may want OAuth instead. One command:
$ keryx mcp auth linear
Opening your browser to authorise "linear"…
Authorised "linear" — stored credential, valid for about 60 more minute(s).
Stored owner-only in /home/you/.config/keryx/mcp-credentials.json.
Four things about it are deliberate:
| Only this command opens a browser. | Starting keryx shell never does. A session that launched a consent screen you did not ask for, or blocked on a headless box waiting for a click nobody will make, are both worse than a refusal that names the command. |
| It exits non-zero without a terminal. | In CI or over a pipe it refuses immediately rather than hanging for five minutes. |
| Tokens live in one file. | mcp-credentials.json, owner-only (0600), keyed by server name and URL — repointing a server at a different host does not send it the credential issued to the first one. Nothing is written to mcp-servers.json, and no command prints a token. |
| The callback is loopback-only. | 127.0.0.1 on an ephemeral port, state validated, one request served, then closed. |
doctor reports a server that needs this as needs_auth and names the
command — including when a stored credential has expired beyond refresh or
the authorisation server has revoked it:
$ keryx mcp doctor linear
linear [user] http: needs_auth — no stored credential; run `keryx mcp auth linear`
If the server has a header or a bearer_token_env_var, keryx mcp auth
says so and changes nothing: it would be starting a flow that cannot help.
Optional, in the config entry:
| Field | Effect |
|---|---|
"oauth": {"clientId": "…"} |
Use a client you registered yourself; keryx then registers none dynamically. |
"oauth": {"scopes": ["read"]} |
Request specific scopes at registration. Without it you get whatever the server issues by default. |
"oauth": {"callbackPort": 8765} |
A fixed loopback port, for an authorisation server that pre-registered one exact redirect URI. Ephemeral otherwise, so two flows cannot collide. |
"oauth": false |
This server is public; never diagnose it as needing a login. |
keryx mcp logout <name> forgets the stored credential. Re-running
keryx mcp auth does not repair a revoked refresh token — the
authorisation server rejects it before any browser opens — so forgetting
it first is the way back.
Three things keryx refuses on purpose. Each of these is a working
configuration elsewhere and a refusal here, so doctor names it rather
than reporting a generic failure:
| Refused | Why |
|---|---|
A redirect (3xx) |
fetch follows up to twenty hops and only strips Authorization across origins — a custom credential header, which is the common MCP pattern, follows all the way. Configure the final URL. |
A username or password in the URL (https://user:pw@host/mcp) |
It appears in every message that names the URL, and the HTTP client drops it anyway: you would get the secret on screen and an unauthenticated connection. Put it in a header or bearer_token_env_var. |
An unset ${VAR} in the url |
https://api.example/${TENANT}/mcp with TENANT unset is a valid URL addressing the wrong path, which otherwise reports as "nothing is listening" and sends you to check a server that is fine. |
What doctor tells apart. For a remote server, "failed" is not one
thing: nothing listening, a host that does not resolve, a rejected TLS
certificate (including the self-signed one a corporate TLS-intercepting
proxy presents), an HTTP error by status, credentials the server refused, a
handshake that never completed, a redirect, and a URL that serves a web
page rather than MCP each get their own message. doctor exits non-zero whenever something needs
you — including a server awaiting trust or a variable you have not set.
In a session: /mcp. Inside keryx shell, /mcp lists the servers
keryx is connected to — status, tool count, why a failed one failed, and
the exact keryx mcp trust <name> a held one is waiting for. It reads the
session's live state and never dials anything itself, so opening it is
free. A server that holds session-trusted tools carries a [trusted] marker
with their count. /mcp trust list prints every trusted tool by full name;
/mcp trust revoke <server__tool> (or all) removes grants. Both also run
while the agent is busy and in the readline shell. See
Permission modes.
Do not confuse it with /integrate, which is the opposite direction:
that is where keryx ITSELF is registered into an editor's MCP config. (The
CLI integrations verb, one letter different, is a separate command: it
installs Keryx's own hooks/instructions into another coding agent, not MCP
wiring.)
/mcp meant the installer before this release and now means the
consumer, matching what keryx mcp has meant on the command line since
the rename.
Approval of tool calls. Every use_tool call goes through the same approval gate as
shell_exec and apply_patch. Under --trust a call still asks, and with no
approver present (headless) it is denied rather than allowed. A server's own
"this tool is read-only" annotation is shown to the model as a hint and is
never allowed to skip the prompt.
The prompt for an MCP call names the server and the tool on their own lines, above the arguments, and never offers "always allow". Both are deliberate: a tool call's arguments are written by the model, so nothing in them may be able to push the tool's identity out of view, and an "always" grant would store a pattern the model chose in your permission file.
Environment. A server is spawned with your environment minus two things,
stripped independently and for independent reasons — the same strip, built
once in buildMcpChildEnv and used by every surface that launches an MCP
server (keryx shell, keryx mcp doctor, and an ACP client's own servers
under keryx acp; see "MCP servers from the client" above):
- Anything credential-SHAPED, by name or by value: provider keys
(
ANTHROPIC_*,OPENAI_API_KEY,GEMINI_API_KEY, …), forge and cloud tokens (GITHUB_TOKEN,NPM_TOKEN,AWS_*), agent sockets (SSH_AUTH_SOCK), credential-file pointers (NETRC,KUBECONFIG, …), the wholeKERYX_*namespace, any variable whose name says it holds a token, key, password or credential, and any value shaped likescheme://user:pass@hostwherever it turns up. - Every variable your saved config (
auth.json) declares, removed by the exact NAME it is saved under — whatever that name is. This catches what the shape rule cannot: the "add custom provider" wizard saves an API key under whatever env-var name it is given, which may carry none of KEY/TOKEN/SECRET (MY_LLM_GATEWAY, say). That name would otherwise reach every server you launch, indistinguishable from a variable you exported yourself — and it holds whether or not THIS run has actually loaded the key into its own process yet:keryx mcp doctorandkeryx shell --print/--no-tui/non-TTY never call the function that would, but the strip reads the same saved names straight off disk, so it applies there too, not only on a path that happened to load them first.
A server that genuinely needs one of these takes it explicitly — -e for a
stdio server, its own env entry either way — which is a decision you made
rather than a default you inherited; an explicit entry always wins over both
strips, even for a name your saved config declares. Its stderr is captured,
not inherited, so it cannot write to your terminal.
$ keryx mcp add fs -- npx -y @modelcontextprotocol/server-filesystem ~/notes
Added "fs" to /home/you/.local/share/keryx/mcp-servers.json (created).
Run `keryx mcp doctor fs` to check it connects.
$ keryx mcp doctor fs
fs [user] stdio: connected
file: /home/you/.local/share/keryx/mcp-servers.json
tools: 14
Not yet in this release: OAuth, importing servers you already configured in
Cursor/Claude/.mcp.json, and a TUI view. See
docs/requirements/keryx-mcp-servers/.
Tool and resource exposure is filtered by the manifest's expose.modules list — a
disabled module is hidden from tools/list and resources/list.
Unlike every other opt-in command, mcp serve hard-fails (prints an actionable
message and exits 1) when the optional @modelcontextprotocol/sdk dependency is
not installed — this is the one sanctioned exception to graceful degradation. An
unknown subcommand prints an error and exits 1.
SAC tools registered in src/mcp/tools.ts (sac.overview, sac.read,
sac.collaboration, sac.propose, sac.review) refuse HTTP with
{ code: "sac_transport_denied" } before workspace discovery. See
Shared Agent Context.
Slate tools, registered in the same file, are the external hand onto the
task-local scratchpad keryx's own shell/TUI/harness run already keep:
slate.open, slate.writeSeed and slate.close. All three mutate, all three
are externalSessionId-scoped and never reachable through a different id, and
all three refuse HTTP with { code: "slate_transport_denied" } before storage
is touched — the same local-stdio-only boundary as sac.*. A closed slate with
a bound workspace dispatches its Seeds into the ordinary SAC propose/review
pipeline, so the human confirm-review gate above still stands between an
external Seed and real project knowledge; a slate that never bound loses
nothing, and surfaces at the next keryx workspace catch-up as an
unbound-candidate. There is no CLI verb — the surface is these three tools
only. See Slate for external agents.
retention¶
Bounds stores that grow with every routed command or refused write and that
nothing else prunes: .metaproject/data/gdctx/raw and artifacts (one file
per keryx ctx rg/ctx read/ctx run invocation — see ctx), and
the per-refused-write conflict sidecars each SAC owner writer leaves under
.metaproject/workspaces/<id>/<owner>-write-conflicts/ when a proposal's base
version no longer matches the target (see workspace review).
Neither store had any retention logic before this command shipped, and gdctx's
was measured at over 300 MiB and growing across two flow reports taken hours
apart.
keryx retention status [--json]
keryx retention sweep [--apply] [--target <id>]... [--max-age-days <n>] [--max-bytes <n>] [--json]
| Subcommand | Flags / args | Description |
|---|---|---|
status |
--json |
Read-only inventory: for each target, its directory, entry count, bytes on disk, and what the policy would remove right now. Touches nothing. |
sweep |
--apply, --target <id> (repeatable), --max-age-days <n>, --max-bytes <n>, --json |
Applies the retention policy. Dry run by default — without --apply, nothing is removed and the report shows exactly what would go. --target restricts the run to one or more target ids from status. --max-age-days/--max-bytes override every target's own cap uniformly for this run only. |
Policy. Two axes, oldest-first, the same shape the wiki freshness queue already caps a single growing file by (lines + bytes) — adapted here for a directory of many small immutable files instead of one log:
- Age is primary. gdctx raw logs and artifacts are the evidence behind routed-search summaries an agent may still want to open, so retention trades recoverability against size deliberately: 14 days by default for gdctx, 30 days for owner write-conflict sidecars (an actual refused write an operator may want to reconcile, not routine search chatter).
- Total bytes is the backstop, applied only after the age cutoff, evicting
the oldest remaining entries until the target is back under its cap. Age
alone does not bound a burst — a single day of heavy
ctx rguse can add gigabytes inside the age window regardless of how old anything else is. Defaults: 200 MiB forgdctx-raw, 50 MiB forgdctx-artifacts, 20 MiB per discoveredowner-write-conflictsdirectory.
Entry count is deliberately not its own axis — for these stores it never fires before the byte cap already would.
Unreachable stores. A target directory that cannot be listed, an entry
that cannot be sized, or an entry that cannot be removed marks that target
incomplete with the specific reason — never folded into a clean success. A
problem discovering targets at all (e.g. .metaproject/workspaces/ itself
unreadable) is reported separately as a discovery issue and also folds the
whole report to incomplete, since a target that was never found is not the
same thing as a target with nothing in it. Either way the command then exits
1.
Scope. This sweeps the local stores named above only. Content already relayed to an agent, copies exported elsewhere, and git history are out of scope and are never touched or promised erased by it — this command bounds size, it does not implement AC-29's tombstone/forget semantics.
remote¶
Previews how a reply is written for the Telegram remote control, without sending anything. It needs no bot token, no network and no running session.
| Subcommand | Flags / args | Description |
|---|---|---|
format-sample |
--mode <mode>, --full, --json |
Prints one fixed reply (a heading, a table, a numbered list with a nested bullet, task items, a rule and a quote) the way each rendering mode would send it. --mode limits it to one mode, --full prints the whole rich message as JSON instead of a count of its blocks, --json is machine-readable. An unknown --mode exits 1 and names the valid ones. |
The mode a running session uses is the remote.rendering setting (auto,
rich, html or plain; default auto). Change it with /rendering <mode>
in the shell or the "Telegram rendering" row of /settings; /channels shows
the mode in effect and the last fallback. See
Drive keryx remotely.
forgetting¶
Read the deletion trail at .metaproject/data/forgetting/journal.jsonl — what
was removed, when, at whose request, and on what basis. Read-only; this command
never writes to the trail. keryx sync --apply is the only thing that appends
to it.
keryx forgetting trail [--limit <n>] [--json]
keryx forgetting lookup "<ref-or-path>" [--layer <layer>] [--search] [--json]
trail prints the records newest first, with the coverage measured from the
file itself: how many records exist, which layers have ever had a removal
recorded, and which layers every record names as untouched.
lookup answers one identity. By default the match is exact on a removal's
ref or page — there is no fuzzy fallback, because a lookup that silently
answers with an adjacent record is worse than one that says it has none.
--search is the separate phrase-matching mode (over ref, page and
title); every hit reports which field matched and which layer it came from.
--layer narrows an identity lookup to one knowledge layer.
Verdicts.
| Verdict | Meaning | Exit |
|---|---|---|
recorded-removed |
the trail names this as removed, with when/who/why | 0 |
no-removal-recorded |
the trail was read and names no removal of it | 0 |
trail-absent |
nothing has ever been appended to the trail here | 0 |
trail-unreadable |
the trail exists and could not be read — removed and never-recorded cannot be told apart | 2 |
The bound on a negative answer. The trail records removals a reconcile
observed. An entry deleted with rm and never reconciled leaves no record, and
neither does any layer the reconcile does not write for. So
no-removal-recorded means exactly "nothing here records a removal" and never
"this never existed"; every rendering of it carries that caveat plus the
coverage that bounds it.
Who else reads this trail. keryx memory search (when a search returns
nothing), keryx wiki check-links (for each broken link) and keryx gdgraph
affected (for a target that is not a node) all consult the same module, so the
trail is read whether or not this verb is typed.
workspace¶
Shared Agent Context operator surface. Thin argv adapter over src/sac/
(src/commands/workspace.ts). JSON on stdout; --explain human text on
stderr. Actor is always the local OS user — there is no --actor. Full help:
keryx workspace --help. keryx commands omits this verb.
keryx workspace create --title <title> [--component <workspace-relative-ref>]
keryx workspace list [--include-archived]
keryx workspace show <workspace-id>
keryx workspace add-resource <workspace-id> --kind <kind> --uri <workspace-relative-ref> [--revision <revision>]
keryx workspace remove-resource <workspace-id> --uri <workspace-relative-ref>
keryx workspace rename <workspace-id> --title <title>
keryx workspace archive <workspace-id>
keryx workspace overview <workspace-id> [--max-items N] [--max-tokens N] [--explain]
keryx workspace read <workspace-id> <item-id> [--max-items N] [--max-tokens N] [--explain]
keryx workspace propose <workspace-id> --kind <decision|wiki-update|memory-entry|follow-up|contract-change|risk> --session <session-id> [--note <one-line>]
keryx workspace list-proposals [<workspace-id>]
keryx workspace review <workspace-id> <proposal-id> --decision <accepted|rejected|dismissed> [--reason <reason>] [--idempotency-key <key>] [--confirm-token <token>]
keryx workspace confirm-review <workspace-id> <proposal-id> [--acknowledge-security]
keryx workspace catch-up [--workspace <workspace-id>] [--json] [--include-lifecycle-flags]
keryx workspace dismiss-candidate <evidence-path|session-id> [--reason <reason>] [--evidence <path>]
keryx workspace handoff <workspace-id> --to <subject> --artifact <ref>
keryx workspace collaboration <workspace-id>
keryx workspace policy-readiness
| Subcommand | Flags / args | Description |
|---|---|---|
create |
--title, --component |
Create .metaproject/workspaces/<id>/workspace.json. |
list |
--include-archived |
List workspaces visible to the local actor. Archived ones are hidden unless asked for. |
show |
<workspace-id> |
Print the manifest. |
add-resource |
--kind, --uri, --revision |
Attach a workspace-relative typed ref. |
remove-resource |
<workspace-id>, --uri |
Detach a resource by its ref. |
rename |
<workspace-id>, --title |
Change the workspace title. |
archive |
<workspace-id> |
Archive a workspace. list hides archived ones unless --include-archived is passed. |
overview |
--max-items (default 32), --max-tokens (default 4096), --explain |
Bounded FWK overview + access receipt. Mandatory overflow → context_overflow and no receipt. |
read |
<item-id>, --max-items (default 1), --max-tokens (default 4096), --explain |
Progressive read of one overview item. |
propose |
--kind, --session, --note |
Immutable proposed record from a completed session wrap-up. |
review |
--decision, --reason, --idempotency-key, --confirm-token |
Terminal review. accepted goes through real wiki/memory/skill owner writers and requires --confirm-token, minted only by confirm-review. Same idempotency key replays. A wiki-update/memory-entry accept also returns a dedup/conflict hint (duplicates/conflicts against already-accepted entries) and, when non-empty, an optional model-judge annotation — both informational only. |
list-proposals |
[<workspace-id>] |
List proposals — for one workspace, or across all of them when the id is omitted. |
confirm-review |
<workspace-id>, <proposal-id>, --acknowledge-security |
Mint the --confirm-token a review --decision accepted call needs. Run this yourself in a real, approval-gated shell — no tool call (MCP or keryx-shell) can mint one. When the proposal's security gate is needs-approval, it prints what the scan found and in which evidence, and then refuses unless --acknowledge-security is passed — the flag is the record that a human read the findings, and the token carries that fact to review. A clean proposal claims no acknowledgement; a proposal whose gate cannot be read is refused rather than assumed to have passed. Before 0.2.75 the flag did not exist and a needs-approval proposal could not be accepted by any route; 0.2.74 briefly passed the acknowledgement unconditionally, which made the gate unfirable. |
dismiss-candidate |
<evidence-path\|session-id>, --reason, --evidence |
Dismiss an unbound-candidate that catch-up surfaced — a wrap-up whose session never bound to a workspace. Takes either the evidence path directly or a session id, which resolves to that session's newest slate archive. |
catch-up |
--workspace, --json, --include-lifecycle-flags (default on) |
Pull-based cwd-scoped digest: pending proposals, blocked runs, unbound-candidate wrap-ups, sessions of unknown fate, and a lifecycle-flags section for any workspace/memory-entry/wiki-decision whose recorded module no longer resolves in the code graph. Report-only — never writes. |
handoff |
<workspace-id>, --to, --artifact |
Record that work moved to another participant. The recorded from is always the subject the authorization server resolved for the caller — there is no flag that sets it (--from is refused by name), so handoff.from can never be a string a caller typed; a payload missing to or artifact is refused rather than written empty. |
collaboration |
<workspace-id> |
Read-only collaboration overview: references, plus the handoffs handoff recorded. An empty activity means none were recorded — before handoff existed it meant none could be. |
policy-readiness |
— | Diagnose the opt-in policy-experiment chain. Exit 1 when !integrityReady. |
Unknown options are rejected. Propose/review use
createHarnessProposalLifecycleService (real owner writers). There is no
session↔workspace auto-bind — except in keryx shell/TUI/harness run,
where an action-intent turn resolves-or-creates one automatically by agent
judgment (/goal <text> [--workspace <id>] [--auto [N]], or harness run
--goal ... [--workspace <id>]); a completed attempt dispatches its own
wrap-up proposal without a manual propose call. Operator guides:
Shared Agent Context,
/goal.
Session shell task commands (flow 406)¶
Both shell surfaces support /tasks, /tasks output <task_id> [cursor], and
/tasks kill <task_id>. Output reads report next_cursor without consuming
the agent's reader. Kill stops the supervised process group without a model
turn. These commands operate only on tasks tracked by this session.
In the TUI, unknown slash commands display a collapsible available-command
list with an eight-line preview (Ctrl+O, then Enter to expand).