Self-learning loop¶
keryx learn turns what actually happens in your agent sessions into
durable, reviewable knowledge — without ever storing a transcript, and
without ever mutating a skill, rule, agent, or memory file except through a
human-typed command at a real terminal.
The pipeline¶
- Observe. A hook or
keryx learn observeappends redacted, digest-only events to a daily JSONL file. Nothing is interpreted yet. - Extract.
keryx learn extractfirst runs the observation-file TTL prune pass (never candidate expiry — that staysprune's own job), then runs five deterministic signals (plus an optional, disabled-by-default model extractor) over the observation window and writes/updatesstatus: candidaterecords. - Review / accept. A human reads a candidate with
keryx learn review [<id>]and decides withkeryx learn accept <id>orkeryx learn reject <id>— both require a real terminal. - Apply. An accepted, non-
review-conventionsrecord becomes a project-skill proposal viakeryx learn apply <id> --skill <module/name>. An acceptedreview-conventionsrecord becomes a reviewer-profile.mdcviakeryx review learn --reviewer <id>. - Promote. A project-scope accepted record that has independently
reinforced across enough distinct projects can be promoted to a user-scope
candidate with
keryx learn promote <id>— itself requiring its own later accept. - Graduate.
keryx learn graduateclusters accepted, reinforced records into a graduation proposal; a human applies it separately. - Prune.
keryx learn prunedeletes observation files past their TTL and expires candidates nobody decided about in time.
Every arrow above except Observe and Extract requires a human to type a command; Observe and Extract never write a decision or mutate a skill, rule, agent, or memory file — see What is observed, and what is never stored and Consent guarantees below.
Passive observation is on by default in keryx shell. The built-in
keryx.learning-observer hook fires on every session-start, session-end, and
tool event and appends one redacted, bounded observation line — see What is
observed, and what is never stored
for exactly what that line contains — to
.metaproject/data/learning/observations/<YYYY-MM-DD>.jsonl, a local,
gitignored file. It never writes through a symlink that leaves the project:
every write goes through the same containment check every other Keryx write
into your project tree uses, so a .metaproject/data/learning/observations
path that turns out to be (or contain) a symlink pointing outside the
project is refused rather than followed — nothing lands outside your
project, ever. Turn it off with either:
(This is distinct from the opt-in Claude Code host
observer below, which is a separate,
still-opt-in integration for when Keryx runs as a hook inside a Claude Code
session rather than as keryx shell itself.)
CLI reference¶
| Command | Effect |
|---|---|
keryx learn observe [--hook claude] |
Without --hook, reports today's observation file line count (manual/offline use). With --hook claude, reads one host-hook payload from stdin, maps it to an observation event, always exits 0, and prints nothing — the command an opt-in Claude Code hook runs. |
keryx learn extract [--domain <d>] [--since <YYYY-MM-DD>] [--json] |
Runs the deterministic signals (and, if enabled, the model extractor) over the observation window, writing/updating status: candidate records. |
keryx learn list [--status <s>] [--domain <d>] [--scope <s>] [--json] |
Lists records with filters. |
keryx learn review [<id>] [--scope <s>] |
Prints one candidate, or all candidates, with its evidence, for a human to read before deciding. |
keryx learn accept <id> [--scope user] [--refresh] |
status: candidate -> accepted. Terminal-only; project scope also writes one index entry (see Cross-project index and promotion). --refresh overwrites only the current project's index entry, without changing status. |
keryx learn reject <id> [--scope user] |
status: candidate -> rejected. |
keryx learn apply <id> --skill <module/name> [--dry-run] |
Renders the accepted record as a LearningProposal and calls the existing applyLearningProposal — the only writer under .metaproject/project-skills/. --dry-run computes and returns the proposal only; it writes nothing durable to disk (not even the proposal JSON). |
keryx learn promote <id> |
Terminal-only; see Cross-project index and promotion. |
keryx learn graduate [--domain <d>] |
Clusters accepted records and writes a graduation proposal. |
keryx learn graduate apply <proposal-id> |
Terminal-only; writes an agent candidate. A skill-target proposal is not applied by this command — see below. |
keryx learn prune [--dry-run] [--json] |
Deletes observation files past their 30-day TTL and expires candidates past ttl.expiresAt. |
keryx review learn --reviewer <id> [--dry-run] |
Applies an accepted review-conventions record as a reviewer-profile .mdc — the reviewer-profile sibling of project-skill apply. |
accept, promote, and graduate apply never take a --yes / --force /
--non-interactive flag: each refuses outright outside a real interactive
terminal, with a named reason, and there is no bypass. None of the three is
reachable through MCP — an agent cannot accept, promote, or graduate its own
learned pattern; only the human at the terminal can.
A graduation proposal whose cluster resolves to an agent target is
applied directly by keryx learn graduate apply, writing
.metaproject/agents/<name>.md with origin.kind: "learned" and
sourceRef set to the source pattern's id. A proposal that resolves to a
skill target is not applied by this command at all — it prints the
equivalent invocation of the existing W1 scout/record path for a human to
run:
What is observed, and what is never stored¶
Observation is deliberately shallow. Each observation event is one line of
JSON appended to .metaproject/data/learning/observations/<YYYY-MM-DD>.jsonl
(one file per UTC day):
| Field | Contains |
|---|---|
event |
One of tool-start, tool-complete, tool-failed, user-prompt, session-start, turn-stop, session-end |
inputDigest |
sha256 of the tool input — never the raw input |
inputPreview |
First 200 chars of the (redacted) tool input |
outputPreview |
Same bound and redaction, tool-complete/tool-failed only |
cwdHash |
sha256 of the resolved cwd — never the raw path (a path can carry a username) |
edit |
Optional, present only for a file-editing tool call: { pathDigest, removedDigest, addedDigest }, each a sha256 hex digest — never the raw path or diff content |
sessionId, toolUseId, project, observedAt |
Session/project bookkeeping, no content |
What this rules out by construction:
- No transcripts. No full tool input, no full tool output, no prompt
text, no file content, no diff content — only sha256 digests and 200-char
redacted previews.
user-promptevents go further:inputPreviewis always the empty string (never even a truncated slice of the prompt) — onlyinputDigestcarries anything about the prompt's content, and that is a one-way hash. - No absolute paths, no home directory, no username. Before a preview is
built, every path-shaped field (
file_path/path/filePath/notebook_path) is rewritten project-relative — or, for a path outside the project, to its basename only, never the full path — and any remaining occurrence of the project root or the caller's home directory in free text (a Bash command, stdout/stderr) is replaced with./~. Raw edit content (old_string/new_string/content/edits/originalFile/structuredPatch, in either Claude's snake_case tool-input or camelCase tool-response shape) is dropped from every preview outright — theeditfield's hashes cover it instead. - Bounded identifiers.
sessionId/toolUseIdmust match^[A-Za-z0-9._:-]{1,128}$andtoolmust match^[A-Za-z0-9_.:-]{1,128}$; anything else is replaced with a deterministich-<hash prefix>(ids) or"other"(tool name) rather than stored as-is. - Redaction before write, not after. Every
*Previewfield passeskeryx security check-outputbefore it is written; a finding (secret, credential, …) replaces the preview with"[redacted:<category>]"rather than dropping the event, so the event still counts for extraction. - Bounded storage. 200 chars per preview field, 5000 events per daily
file (further events roll to the next UTC-boundary file early), and a
30-day TTL:
keryx learn prunedeletes an observation file 30 days after its date. - Gitignored.
.metaproject/data/learning/observations/and.metaproject/data/learning/candidates/are in the managed ignore blockkeryx initandkeryx updatewrite to.git/info/exclude(unless your own ignore rules already cover them) — neither ships in your repository. - Off switch.
KERYX_LEARNING=offmakes both the manualkeryx learn observepath and the host-hook--hook claudepath no-ops; nothing is appended. The comparison is an exact, case-sensitive match against the literal string"off"—"OFF","0","false", and"no"are not recognized and do not disable observation; onlyKERYX_LEARNING=offdoes.
Known limitations¶
The edit digests (pathDigest, removedDigest, addedDigest) are unsalted SHA-256 hashes. Someone holding both an observation file and access to the repository could potentially recover a file path by enumerating and hashing the repository's file list, or guess short edited strings by brute-force hashing. This design is acceptable because observation files remain local, are gitignored, are pruned after 30 days, and are never exported or promoted outside the local machine. Should observation data be exported in the future, these digests must be replaced with an HMAC scheme keyed with a per-project local salt.
Opt-in host observer (Claude Code)¶
An opt-in learning-observer surface (flag observe) lets a Claude Code
hook call keryx learn observe --hook claude on every tool and session
event (PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit,
SessionStart, Stop, SessionEnd), mapping Claude's own hook payload
shape onto the same observation event shape used everywhere else. Install it
with either the flag or the surface id:
keryx integrations install --runtime claude --surface observe
# or, equivalently:
keryx integrations install --runtime claude --surface learning-observer
It is opt-in by design (plan decision D5): installing the claude runtime
with no --surface never installs this observer, the same way the agents
surface is opt-in — only naming its flag or id does. It shares the Claude
settings file (.claude/settings.local.json by default, .claude/settings.json
under shared scope) with the ctx-guard/orient/security-check surfaces,
composing by sentinel so none of them clobber each other's hook entries. The
command it installs, keryx learn observe --hook claude, always exits 0
and prints nothing to stdout, so it never affects Claude's own control flow —
regardless of what happens inside the observation sink. Each installed hook
entry carries a 5-second timeout, stdin is read with a 2-second internal
deadline and capped at 1 MiB (a larger payload is dropped, not parsed —
still exit 0), and the security scan run over every preview never touches
.metaproject/data/security/ (no state or incident write), so a busy
session's observation traffic cannot race or pollute a keryx security scan
run.
observe --hook claude resolves the project root from $CLAUDE_PROJECT_DIR
(when it names a directory that already has a .metaproject/) or by walking
up from the process's working directory to the nearest ancestor that does —
never by creating one. When neither is found, it writes nothing and still
exits 0; it never falls back to treating a bare .git checkout, or the raw
working directory, as the project root the way the manual keryx learn
verbs do.
The confidence model¶
Every learned-pattern record carries a numeric confidence in [0, 1],
derived from fixed, documented constants — never a runtime-tunable
parameter:
| Constant | Value |
|---|---|
| Seed, deterministic extractor | 0.4 |
| Seed, model-backed extractor | 0.3 |
| Reinforcement rate | 0.35 |
| Contradiction rate | 0.5 |
| Decay rate | 0.02 per day |
| Max evidence weight | 1.5 |
Each new evidence item updates confidence:
confidence' = clamp(
confidence + (kind == "reinforcement"
? (1 - confidence) * 0.35 * weight
: -confidence * 0.5 * weight),
0, 1
)
Worked example: a deterministic candidate seeds at confidence 0.4. One
reinforcement at weight: 1.0 moves it to exactly 0.61
(0.4 + (1 - 0.4) * 0.35 * 1.0).
Decay runs once per whole UTC day with no new evidence:
confidence' = confidence * (1 - 0.02) ^ days.
confidenceLevel is derived, never authored directly:
| Level | Range |
|---|---|
low |
< 0.5 |
medium |
0.5 <= confidence < 0.8 |
high |
>= 0.8 |
At confidence 0.61 the example above is medium.
Project identity¶
A learned pattern's project field is a stable identity that survives a
clone or a rename:
- If the project has a git remote,
identityis a sha256 hash of the normalized remote URL (protocol, credentials, trailing.git, and case differences are all stripped or lowercased first, so the same conceptual remote hashes the same way regardless of how it is spelled). - Otherwise,
identityfalls back to a sha256 hash of the resolved (symlink-free) worktree root path.
identityKind records which of the two applied ("remote-hash" or
"path-hash"); an optional displayName is review-UI-only and is never used
for identity comparison or dedup.
Cross-project index and promotion¶
Accepting a scope: project record additionally writes one entry —
{projectIdentity, identityKind, confidence, acceptedAt} — under that
pattern's id in ~/.keryx/learning/index.json. Accepting a scope: user
record changes only its own status; it writes no index entry.
A pattern becomes eligible for promotion once its id has independently
reinforced in two or more distinct project identities, each indexed at
confidence >= 0.8. keryx learn promote <id> then:
- refuses outright in a non-TTY context, with a named reason, and has no bypass flag;
- refuses a fixture with only one qualifying identity, even when run interactively, with its own named reason;
- uses each identity's indexed, accept-time confidence, not the live source record's current confidence — so a record that has since decayed or been contradicted in one project does not silently retro-qualify;
- requires typing the pattern's id back to confirm; and
- on success, writes a new
scope: user,status: candidaterecord under~/.keryx/learning/patterns/— never an automaticaccepted. The promoted candidate needs its own human accept at user scope, same as any other candidate.
keryx learn accept --refresh <id> is the one way to update an already
-indexed entry: it overwrites only the current project's own entry with the
source record's live confidence, without changing status.
Team-scope learned patterns are currently local-only, not shared through git — see Move skills, rules, agents, and memory between projects and machines for why, and for the bundle export/import path that moves one to another machine explicitly.
Consent guarantees¶
- Terminal-only mutation.
accept,promote, andgraduate applyeach refuse outside a real interactive terminal, with a named reason, and none accepts a bypass flag (--yes,--force,--non-interactive, …). None of the three is exposed through MCP, so an agent cannot drive them on its own. - Decisions log. Every human action that changes a record's status
appends one line to
.metaproject/data/learning/decisions.jsonl(or the user-scope equivalent):{schemaVersion, action, id, scope, actor, tty: true, at}.actionis one ofaccept,reject,refresh,promote,graduate-apply. acceptedis unforgeable.keryx learn acceptis the only code path that can producestatus: "accepted"— the store itself refuses anacceptedwrite from any other caller.extract,prune,promote, and the graduation pipeline never produceaccepted.- The exit guard.
auditAcceptedRecordsflags any accepted record (either scope) that has no matchingacceptdecision in its scope's log — the property a CI test pins so nothing can reachacceptedsilently. - Injection and secret scanning. Every candidate's
trigger,action, and evidence text is scanned before it is persisted, and again beforeapply/graduate/ reviewer-profile writes. Injection-shaped text (e.g. "ignore previous instructions…") and secrets are refused outright — never stored, not even redacted. - Path boundaries.
acceptrefuses any target outside.metaproject/data/learning/or~/.keryx/learning/; project-skillapplyrefuses outside.metaproject/project-skills/; reviewer-profileapplyrefuses outside.metaproject/rules/reviewers/;promoterefuses outside~/.keryx/learning/.
See also the skill-lifecycle amendment in
.metaproject/rules/core/skill-lifecycle.mdc
("Passive observation is not mutation"): a hook may append an observation
event, and nothing more — extraction, accept, apply, promote, and graduate
are all agent-loop, human-triggered work, never hook-triggered.
Reviewer profiles¶
A domain: "review-conventions" candidate, extracted from comments by an
author configured in .metaproject/review-learning.config.json's
reviewerProfiles list, carries an opaque reviewerProfile.reviewerId —
"rv-" plus 16 hex characters of a per-project hash of the login. The login
itself is never stored in the rendered file:
reviewerIdFor(projectIdentity, login)is a one-way hash; the same login hashes differently per project, and there is no reverse mapping.generalizeLesson()strips@mentions, every configured login (case-insensitive, word-bounded), and first-person/courtesy phrasing before a comment becomes trigger/action text.keryx review learn --reviewer <id>renders.metaproject/rules/reviewers/ <id>.mdcand refuses to write output containing any configured login.reviewerProfilesin the review-learning config must be a subset ofauthors— a login cannot be profiled unless it is already a recognized review author.
No-attribution filtering¶
Configured reviewer logins are removed from learned text, and any remaining occurrence is refused, by matching the login at identifier boundaries. A boundary is any character other than a letter, a digit or -; _ counts as a boundary. This catches @login, login[bot], login's and login_. The filtering deliberately does not match a login glued to other letters with no boundary (for example alicedev inside alicedeveloper) — that is a known, accepted limitation, chosen so that short logins and ordinary words never block lessons, applies or graduations. Reviewer ids in files are always the opaque rv- hash.
Model extractor capability (disabled by default)¶
keryx learn extract can, in principle, run a model-backed extractor
alongside the five deterministic signals (repeated-correction,
reverted-edit, failing-to-passing-test, reviewer-comment,
health-regression). It refuses to run unless explicitly enabled in
.metaproject/learning.config.json:
No model-backed extractor implementation ships with this release — the
capability gate exists, but modelExtractor: true currently enables a port
with nothing behind it. The deterministic core never depends on this file
existing; an absent or malformed config is equivalent to every capability
disabled.
File layout¶
| Path | Scope | Contents |
|---|---|---|
.metaproject/data/learning/observations/<YYYY-MM-DD>.jsonl |
project | Append-only observation events (gitignored, 30-day TTL) |
.metaproject/data/learning/candidates/<id>.json |
project | Every project-scope record, any status (gitignored) |
.metaproject/data/learning/decisions.jsonl |
project | Append-only human decision log |
.metaproject/data/learning/graduation/<proposal-id>.json |
project | Graduation proposals |
.metaproject/data/learning/learn.lock |
project | File lock for the writers above |
.metaproject/learning.config.json |
project | Model-extractor capability gate (optional, absent = disabled) |
.metaproject/review-learning.config.json |
project | Reviewer-comment learning config: skill, repo, authors, reviewerProfiles |
~/.keryx/learning/patterns/<id>.json |
user | Promoted (user-scope) records |
~/.keryx/learning/index.json |
user | Cross-project accept-time index, keyed by pattern id |
~/.keryx/learning/decisions.jsonl |
user | User-scope decision log |
~/.keryx/learning/learn.lock |
user | File lock for the user-scope writers above |
See also: W3 — Self-Learning Loop
(the design spec this page tracks) and
learned-pattern.schema.json
(the record schema).