Design contract
The implementation contract: verified omp facts, the deviations they force, and every interface the crates, the extension and the adapters share. Where SPEC and DESIGN disagree, DESIGN wins.
Source of truth for every slice. The build spec is docs/SPEC.md; this file records
the verified omp facts, the deviations forced by them, and the exact interfaces the
crates, the extension and the improver share. When SPEC and DESIGN disagree, DESIGN
wins (it exists only where a technical constraint was verified).
0. Verified omp facts (omp v18.12.6; omp://extensions.md, omp://skills.md, probes)
Section titled “0. Verified omp facts (omp v18.12.6; omp://extensions.md, omp://skills.md, probes)”| Fact | Consequence |
|---|---|
resources_discover is implemented in ExtensionRunner but no AgentSession callsite emits it |
Cannot contribute skillPaths at runtime. Replaced (see §1). |
lookup("skills.customDirectories").override(pi.pi.settings, [...]) executed inside the extension factory lands before loadSkills(): probe showed the custom-dir skill in before_agent_start.systemPrompt |
Load mechanism: factory has the binary sync the shared skills dir for cwd (skills sync), then overrides the setting additively ([...configured, REMATCH_ROOT/skills]). Decision holds for the process (spec already caveats). |
Skills of the same name are deduplicated by realpath: identical files collapse into one (omp://skills.md) |
A kept entry reachable natively (~/.omp/agent/skills/<name>) and through its link in the shared dir REMATCH_ROOT/skills/ loads once (§16 “Shared skills”). |
| In the TUI a plugin extension that registers no provider/shortcut/composer-shape/file-fallback may be lazy-loaded after the composer accepts input | Extension registers one shortcut (digest) so it loads eagerly, before discovery. |
session_shutdown handlers get 2 s, run concurrently |
Shutdown only writes a flag file; nothing long-running. |
pi.exec(cmd, args, {cwd, timeout, signal}) → {stdout, stderr, code, killed}; buffered; available during factory load |
Short binary calls only. Long work: binary daemonizes itself (setsid) and returns a job id. |
tool_result event: {toolName, toolCallId, input, content, details, isError}; no “active skills” field |
Skill activity inferred from read of skill://<name> / .../skills/<name>/SKILL.md and /skill: tokens in input. |
input event: {text, images, source} at ingress, interactive+rpc only |
Trace user turns here. ctx.isIdle() false at input ⇒ takeover/steer. |
Session JSONL: ~/.omp/agent/sessions/<slug>/<ts>_<uuid>.jsonl; line 1 is a padded {"type":"title"} slot, line 2 {"type":"session","version":3,"id","timestamp","cwd"}; entries are a tree (id,parentId); message.role ∈ `user |
assistant |
--mode json -p --no-tools --model <provider/id> emits JSONL events; assistant messages appear under `message_start |
message_update |
Custom modelRoles keys are allowed in ~/.omp/agent/config.yml and <repo>/.omp/config.yml |
rematch-editor / rematch-classifier roles read by the binary from config.yml (no modelRoles API for externals). Fallbacks: editor→slow→default; classifier→smol→tiny→default. |
Native extension roots: ~/.omp/agent/extensions/{*.ts, <dir>/index.ts}; a subdir’s non-index *.ts is not discovered |
~/.omp/agent/extensions/rematch-hooks/<id>.ts files are inert to omp; the main extension tool_call handler dynamically imports them (the spec’s “run approved compiled verifiers”). |
omp plugin install <local-path> links the dir into ~/.omp/plugins/node_modules/<pkg.name> and enables it in omp-plugins.lock.json; package.json#omp.extensions lists entries |
Install: omp plugin link <root>/extension (or install). Binary on PATH via ~/.local/bin/rematch shim written by scripts/install.sh. |
omp anonymize writes a shareable redacted copy of a session |
tests/goldens/ holds anonymized real sessions for the importer tests. |
Credentials, agent.db, models.db live in the agent dir |
Batch isolation does not swap PI_CODING_AGENT_DIR (would lose auth). Isolation = fresh git worktree + --config <overlay.yml> (all skill providers off, skills.customDirectories=[candidate], memory off) + --no-extensions --no-rules --no-session. |
Task agents are discovered from <cwd>/.omp/agents/*.md (project), ~/.omp/agent/agents/*.md (user) and <extension-root>/agents/*.md of every enabled plugin, in that precedence, first name wins; frontmatter needs name + description, extra keys are ignored (omp://task-agent-discovery.md). No setting adds an agent dir at runtime |
Provisional agents load through the plugin root: extension/agents is a symlink (made by install.sh, gitignored) to REMATCH_ROOT/agents/, which holds one symlink per approved provisional agent (library approve; an unapproved agent is linked nowhere, §16 Subagents row). They are visible in every session while provisional and approved (counted only where the predicate’s glob hits); kept agents are copied to ~/.omp/agent/agents/<name>.md (an agent reaches kept only by loading, which needs the approval). A user or project agent of the same name shadows a provisional one. |
Agent dir resolution (omp://config-usage.md#profiles, omp://extension-loading.md, @oh-my-pi/pi-utils/src/dirs.ts): PI_CONFIG_DIR replaces ~/.omp; OMP_PROFILE (when defined, including empty/default which selects default and suppresses PI_PROFILE) else PI_PROFILE selects ~/.omp/profiles/<name>/agent (ignoring PI_CODING_AGENT_DIR); in the default profile PI_CODING_AGENT_DIR overrides ~/.omp/agent (unless it equals a profile-derived agent dir inherited from PI_PROFILE). |
RematchPaths::resolve() follows omp’s exact precedence; REMATCH_ROOT stays $REMATCH_ROOT or ~/.rematch (shared across profiles). import --history without --sessions imports the default agent dir plus every <config_root>/profiles/*/agent/sessions except profiles in --skip-profile <name> or policy.json {"skip_profiles":[...]}. |
context event (pi-coding-agent/src/extensibility/shared-events.ts ContextEvent {messages}; extensions/runner.ts emitContext): runs before every provider request on a structuredClone of the messages; each handler’s returned {messages} feeds the next; the persisted history is untouched; each handler is bounded by a 30 s timeout (EXTENSION_HANDLER_TIMEOUT_MS). Message shapes (pi-ai/src/types.ts): user {content: string | blocks, synthetic?}, toolResult {toolCallId, toolName, content: (text|image)[], isError}; assistant tool calls are {type:"toolCall", id, name, arguments} blocks. A CustomMessage {role:"custom", customType, content, display, details?, timestamp} (pi-agent-core/src/compaction/messages.ts) is sent to the provider as a developer message (convertMessageToLlm, reached from session/messages.ts convertToLlm) |
truncate and retrieve hooks rewrite only the outgoing copy: truncation replaces old toolResult text, retrieval appends one hidden CustomMessage (customType: "com.vncp.rematch.retrieval"). |
Native Windows portability
Section titled “Native Windows portability”The Windows delivery target is the omp integration. scripts/install.ps1 builds the
binary, creates the native file/directory links, registers the extension and installs
rematch.exe in the user’s .local/bin. Developer Mode or an elevated token is required
for symlink creation. Install/uninstall honor HOME, then USERPROFILE.
Process lifetimes use an owned Windows Job Object: the child starts suspended, joins the job, then resumes; timeout or cancellation terminates its descendants too. Detached jobs either leave a kill-on-close parent job or return an explicit refusal. Unix retains process groups and SIGTERM/SIGKILL. Locks use OS locks rather than pid-file ownership; trace writers lock a distant byte on Windows so ordinary readers are not blocked.
For .cmd/.bat omp launchers, prompts travel unchanged through stdin instead of argv.
Executable launchers and Unix retain the existing argv contract. This applies to both
editor calls and batch rollouts; unbounded shell quoting of model-generated text is not used.
The native CI workflow covers Windows and Linux, including portable process/lock tests. Windows also runs an isolated PowerShell install → CLI/profile/lake → uninstall smoke. The omp host and model provider are not exercised by that smoke: its omp shim implements plugin-management/version operations only. Pi/OpenCode runtime Windows parity remains unverified.
1. Topology
Section titled “1. Topology”extension/index.ts ──pi.exec JSON envelope──▶ rematch (Rust, static) │ writes ~/.rematch/traces/<session>.jsonl │ owns ledger.sqlite, staging/, library, improver, jobs │ writes ~/.rematch/pending/<session>.json │ spawns `omp -p --no-tools --mode json` (editor, classifier) │ factory: `skills sync --cwd` → customDirectories │ writes staging/<branch>/<name>/ and ~/.omp/agent/skills/<name>/ │ += ~/.rematch/skills; `match --cwd` → hooks │ links ~/.rematch/skills/<name> → staging or kept entry dir │ extension/agents → ~/.rematch/agents (task agents) │ links approved ~/.rematch/agents/<name>.md; copies kept agents to ~/.omp/agent/agents/<name>.md └ tool_call / context: runs ~/.omp/agent/extensions/rematch-hooks/<id>.ts (block, stuck / truncate, retrieve → `retrieve --query`)Root REMATCH_ROOT = $REMATCH_ROOT or ~/.rematch (shared across profiles). Config root = ~/$PI_CONFIG_DIR or ~/.omp. Agent dir = <config_root>/profiles/<name>/agent when OMP_PROFILE (else PI_PROFILE) names a profile ≠ default, else $PI_CODING_AGENT_DIR (default profile only), else <config_root>/agent.
REMATCH=0 or REMATCH_ROOT/off file ⇒ extension registers nothing but /rematch on and the rematch tool; binary refuses reflect/stage.
Files under REMATCH_ROOT
Section titled “Files under REMATCH_ROOT”traces/<session_id>.jsonl extension / adapter trace (schema §3); Claude Code sessions `cc:<uuid>`, Codex `codex:<id>` (§18); on Windows the file name is the id through `paths::encode_file_stem` (`cc%3A<uuid>.jsonl`: `:` would address an NTFS stream), the id itself elsewherepending/<session_id>.json {session_id, flagged_at, reasons:[...], trace} written at shutdown; deleted by reflect; named like traces/skills/<name> symlink → staging/<branch>/<name>/ or the kept dir, one per loadable entry: the one skill dir omp, Claude Code and Codex load (§16 "Shared skills")ledger.sqlite blobs/<sha256> ledger (schema §5) + content-addressed blobsstaging/<branch>/<name>/ provisional entries (loaded through skills/ once a synced cwd matched them; agents via agents/)agents/<name>.md symlink → staging/<branch>/<name>/AGENT.md per approved provisional agent (`library approve`); `extension/agents` links to this dir, so omp discovers them from the plugin rootkept-agents/<name>/ entry dir of a kept agent (AGENT.md, rematch.json, EXPERIENCE.md); omp loads the copy ~/.omp/agent/agents/<name>.mddrafts/<name>/ draft entries (never loaded)archive/<status>/<id>/ retired|dormant|stale entries (never loaded, never deleted)predecessors/<name>/<version>/ copies of replaced kept entries (rollback); agents: predecessors/.agents/<name>/<version>/ (version 0 = the user's own ~/.omp/agent/agents/<name>.md)improver/{editor.md,composer.md,reflector.md,classifier.md,lint.toml,domain_rules.toml,omp-no-tools.yml} shipped in the binary; written on first run and refreshed on upgrade only if the installed copy still matches the previously shipped hash (`.shipped/<name>`); EXPERIENCE.md files live next to entriesjobs/<job_id>.json jobs/<job_id>.log detached job stateruns/<task>/<seed>/<variant>/ batch workspaces {wt/, skills/, overlay.yml, events.jsonl} (deleted after receipt)runs/<task>/receipt.json last batch receipt; runs/<task>/traces/<session>.jsonl reference traces for `create --from task`tasks/<task_id>.json batch task definitionsbranches/<branch>/domain.yml facet vocabulary per branch; its `domain:` is the branch's work domain (§6 "Domain")policy.json `environment`/`export_enabled` (§15 export), `skip_profiles`, `domains` and `caps` (§6 "Domain", §8); written only by `rematch policy`; with a profile a link to `profile/config/policy.json` (§20)export/ git-tracked work↔home export of invariant entriesprofile/ git working tree synced across machines (§20): skills/<name>/ (kept + adopted), agents/<name>.md, fragments/<name>.md, hooks/<id>.ts, config/{policy.json,roles.yml,domains/<branch>.yml}, improver/<edited file>, profile.json; never removed by `uninstall`.profile.lock profile-sync.log one `profile sync` at a time; the launchd job's output (§20)builtins.version .builtins.lock hash of the shipped built-in entries (§10) and the install locklake/ lake-index/ .lake.lock the lake (§19.1): journal + closed sessions as Parquet, written by `lake export`; Bellwether indexes per machine (L3, never synced); export/import/rebuild lockoff kill switch marker2. CLI (binary rematch)
Section titled “2. CLI (binary rematch)”Every subcommand prints exactly one JSON line on stdout:
{"ok":true,"operation":"<op>","data":{...}} (exit 0) or {"ok":false,"operation":"<op>","error":"..."} (exit 1; 2 usage; 130 cancelled).
Human-readable output only with --text. Logs go to stderr.
doctor {ready, problems, harnesses:{omp, claude-code, codex, profile, lake, built-ins, hooks}, omp, roles, extension, ledger, improver, paths, profiles, pending, off, lines}: one block per harness/service, each {status: ok|not set up|problem, problems, …}: omp (binary + version, extension linked in omp-plugins.lock.json/node_modules and loadable: package.json is @vncp/rematch and every omp.extensions entry exists), claude-code (<CLAUDE_CONFIG_DIR or ~/.claude>/skills/rematch present and resolving, plugin.json + hooks.json parse, skills/ reaches REMATCH_ROOT/skills, `claude plugin validate` passes — $CLAUDE_BIN else claude on PATH; static checks only without one), codex (~/.agents/skills/rematch reaches REMATCH_ROOT/skills), profile (§20: origin, ahead/behind as of the last fetch, conflicts, launchd: plist written → `launchctl list com.vncp.rematch.profile-sync`, $LAUNCHCTL_BIN else launchctl), lake (§19: journal rows past this machine's watermark, the index pin against the Bellwether binary), built-ins (§10: each one's status; not installed = problem), hooks (every `<agent_dir>/extensions/rematch-hooks/*.ts`, `README.md` ignored: `{dir, live, profile, orphan}`; `live` = a local entry of that id is provisional/kept/pinned, `profile` = no live local entry but `profile/hooks/<id>.ts` exists, so the module was approved on another machine and is inert here (information only), `orphan` = neither: a problem, `orphan hook module <path>: no live entry; delete the file`); also roles resolved (provider/model), ledger schema version, improver files, root + agent_dir + profiles (active/existing/skipped), off. A harness that is not set up is never a problem; one set up and broken is. `ready` = no problem. Text: one line per harness (`<name>: <status> · …`), then roles, ledger, agent_dir, root, `problem: …` lines, and last `ready: true` or `ready: false (N problem(s))`import --history [--harness omp|claude-code|codex|all] [--sessions <dir>] [--skip-profile <name>...] [--forge <path> [--migrate]] --harness default all (each harness's default dir that exists); --sessions = the selected harness's dir (with all: an omp dir); clean imported Codex sessions that loaded entries get their synthesized trace written (count folds them)reflect --pending [--session <id>] [--editor <cmd>] [--detach] [--history [--since YYYY-MM-DD] [--limit n]] idempotent per pending file (lock `pending/.lock`); --editor overrides every model call (tests); --detach → {job_id}; --history first hands flagged imported sessions to the loop (≤ n pending files per run, default 10)match --cwd <dir> [--inputs <json>] {branch, repo, skill_dirs:[...], entries:[{id,name,kind,status,predicate_hit,version,sha}], hooks:[path...], prompt_fragments:[{id,name,text}], off:bool, domain, profile_present} (approved provisional agents are listed in every session, unapproved ones not at all; they add no skill dir; `prompt_fragments` = the approved personal `AGENTS.fragment.md` of the listed provisional/kept/pinned entries with `predicate_hit`, ordered by name, their total text capped at the lint `always_on_bytes` (extra fragments dropped), §16 "System prompt"; skill_dirs is kept for compatibility, harnesses load skills/; `domain` = the place's declared domain (§6 "Domain"), or with --inputs the full decision, which then also gates predicate_hit; `profile_present` = `profile/.git` exists, §20)skills sync --cwd <dir> [--harness omp|claude-code|codex] {added, pruned, total, dirs, links:{name: target}, untouched, visible:[entry id], off, cwd, repo, profile_sources, harness, migrated, lines} (+ plugin_link and agents {dir, added, updated, pruned, untouched, present, total} with claude-code when the plugin is found — $CLAUDE_PLUGIN_ROOT, else ~/.claude/skills/rematch —, reached_from with codex): syncs REMATCH_ROOT/skills for the cwd (§16 "Shared skills"; `profile_sources` = the profile's kept skill dirs, §20) and, for claude-code, the plugin's agent copies (§18.1)digest --cwd <dir> {lines:[...], approvals:[{id,name,kind,diff,reason}], ship_ready:[{id,name,scope,detail,command}], review:[...capability floor...], transfers:[...], domain_proposals:[{remote,domain,sessions,command}], profile_conflicts:[path]} (a proposal is one informational line `declare domain <d> for <remote>: rematch policy domain set <remote> <d>`, §6 "Domain"; never applied; `profile_conflicts` = `profile/.conflicts`, one `profile: N conflict(s) not synced …` line, §20; an approval is one item per entry whose `kind` joins its pending parts with `+` in the order `scripts`, `hook`, `agent`, `prompt` — e.g. `prompt`, `agent`, `scripts+prompt` — and `diff` concatenates them, truncated to 4,096 bytes, §14 "Approvals"; `ship_ready` = ship candidates, `scope` team|fork, `command` = `/rematch ship --scope <scope> --entry <id>`, §15)record --session <id> --event <json> accepts `{"ev":"mark","class":…}` only (= mark); every other event belongs in the trace filetrace-event --harness claude-code [--session <id>] [--hook-output] one Claude Code hook payload on stdin → §3 records in traces/cc:<uuid>.jsonl (§18.1); --hook-output prints the hook reply instead of the envelope and exits 0 on any errorhook-check --harness claude-code [--session <id>] [--hook-output] one PreToolUse payload on stdin → {block, hook_id?, reason?}: approved block/stuck hooks for the payload cwd; a block is traced as tool_blocked and, with --hook-output, exits 2 with the reason on stderrcreate <name> --from sessions|task|description [--task <id>] [--branch <b>] [--description <text>] [--files <p>...] [--ran <word,…>] [--editor <cmd>] --ran (sessions only): the references are the sessions whose bash commands have every word (case-folded, §19.2 text keys), ranked by overlap with the request without a minimum; from the lake's events `text:tool_cmd` when the lake holds this ledger's journal, else every ledger session's records; {…, references, source, ran}improve <skill> [--split-only] [--branch <b>] [--editor <cmd>]activate <name> [--task <id> | --unverified]run --task <id> --verifier <cmd> [--width k] [--seed n] → {job_id}run-worker --job <id> internal, detachedstatus [job]library ls | why [<session_id>] [--asof <ts>] | disable <id> | rollback <name> | pin <id> | unpin <id> | approve <id> | decline <id> | set-fork-path <id> <repo-relative path> [--repo <fork>] | export [--to <dir>] | import-export [--from <dir>] (--asof: §19.3; ls and why also list `profile_kept`: profile/skills/* no entry of this ledger holds, one `kept (profile, from <machine>) …` line each, never counted here, §20; `approve <id>` approves every pending part of the entry in one call, one receipt each, and bails only when nothing is pending, §14 "Approvals"; `ls` entries also carry `pending` (the pending parts, `scripts`|`hook`|`agent`|`prompt` in that order, empty unless provisional/kept/pinned), `has_fragment` (the file exists), `prompt_active` (provisional/kept/pinned with a fragment whose sha256 equals the approved one: appended wherever the predicate hits), `fork_path` and `ship_ready` (`team`|`fork`))task add <id> --repo <path> --prompt <text> [--verifier <cmd>] [--max-time 10m] [--tools ...]transfer suggest | transfer apply <lesson_id> --branch <b>ship --scope team|fork [--repo <path>] [--entry <id>...] (fork: --repo defaults to the entry's fork_repo)count [--session <id>] [--editor <cmd>] fold finished traces into counters and decide (also run by reflect --pending)mark --session <id> --class correction|preference|scopeanchor replay <entry> one anchor replay for the kept entry <entry> would replace (§7); reports {kept, anchor:{outcome: replayed|no_verifier, pass, receipt}}, changes no status, writes no receiptretrieve --query <text> [--budget 6000] [--min-overlap 2] [--entries <id,…>] {units:[{entry_id,name,path,title,text,overlap}], used_bytes, query_stems}: units of the package entries loaded for --cwd (match entries with a predicate hit, plus kept/pinned), restricted to --entries (§10 retrieve)builtins ls | reinstall [<name>…] built-in context entries (§10): list with status/approval/counters; re-stage the ones that are not livelake export [--all] | import --from <lake> | rebuild [--from <lake>] [--force] [--machine <m>] | doctor | index [<table>…] [--repin] | query <table> --key K (--values v,… | --has w,…) [--from --to] [--valid --known] [--latest] [--all] [--limit n] | migrate the lake (§19.1, §19.2; --has: a text key's words, all required); export also runs, bounded, at the end of `reflect --pending`; rebuild runs before the ledger is opened; every subcommand but `migrate` needs the cargo feature `lake` (default on) and otherwise fails with "built without the lake feature"; `migrate` also (re)applies the domain policy to sessions and entry predicates (§6 "Domain")report [--window 6w] [--since YYYY-MM-DD] the kill criterion (ADR 001 L4, §19.3): {source: lake|ledger, lake_lag, machines, window, kept:[…], triggers:[…], domains:[…], verdict, profile_kept (with a profile: the count of profile skills no entry here holds, one `profile-kept (not counted on this machine): N` line after the header), lines}tune [--dry-run] [--entry <id|name>] [--window 6w] predicate tuning (§6 "Predicate tuning"): {source: lake|ledger, window, dry_run, entries:[{id, name, version, status, predicate_hash, signals:{read_missed, matched_unread, matched_read, cross_domain_reads, cross_place_reads, source, window_ms}, proposal|null, outcome: staged|would_stage|skipped|no_change, reason?, staged?}], lines}; also run by `count` after its decisions (reported under `tune`)policy show | domain set <remote|glob|repo path> <domain> [--subpath <glob>] | domain unset <remote|glob|repo path> [--subpath <glob>] | caps set kept_per_domain <n> the only writer of policy.json (atomic replace, other keys kept; §6 "Domain", §8); `domain set|unset` report how many recorded sessions `lake migrate` would re-decideprofile init [--remote <url>] [--adopt] [--timeout 10] | adopt <path> | sync [--no-push] [--timeout 10] | status | install-launchd [--interval 300] the profile (§20): init = git init or clone, then a sync; sync = commit, fetch + rebase (conflicts aborted, reported), relink the views, push, write profile/.index.json; adopt refuses other harnesses' files (§20); {offline, conflicts, pulled, pushed, committed, views, collected, lines}off | onuninstall [--yes]reflect --pending performs, in order: count (fold all finished traces not yet counted → counters → decide → EXPERIENCE.md), then one reflection per pending file respecting rate limits, then one bounded lake export (§19.1; reported under lake, an error is logged and reported there, never fatal; absent when built without the lake feature). With --history it first stages, newest first, every imported session (source: import) that is flagged, has no trace file and no history_reflected_at marker: traces/<sid>.jsonl from its records — the lake’s events file when there is one (§19.3), else synthesized from its harness transcript (§3, mode: "import") — and pending/<sid>.json (source: "import"); subagent sessions (session_start.agent.kind == "sub") and sessions the extension’s rule does not flag are marked and skipped. The envelope’s history.sessions gives each session’s outcome (staged, keep_base, rate_limited, skipped + reason, …), history.source/history.sources where the records came from.
3. Trace schema (extension → binary)
Section titled “3. Trace schema (extension → binary)”traces/<session_id>.jsonl, one JSON object per line, written append-only by the extension:
{"seq": <u32>, "t": <unix ms>, "ev": "<name>", ...}. The extension never reads it back.
| ev | fields |
|---|---|
session_start |
session_id, cwd, repo (git toplevel or null), branch_hint (basename), model {provider,id}, mode, agent {kind,name}, omp_version, loaded_entries [{id, version, sha}...] (match entries with a predicate hit, minus retrievehooks, which enter throughretrieval; sha= the entry'scontent_sha256; the plain [id…]of older traces is still read,trace::LoadedEntry), hooks_loaded [id...], staging_dirs [...], repo_remote (origin URL as git prints it, or null; fold_tracenormalizes it,identity::normalize_remote), subpath (cwd relative to repo, “”at the top), head_sha (HEAD at start, null before the first commit), visible_entries [id...] (entries behind the shared skills dir's links,SkillsSync::visible, §16) — ADR 001 identity; the omp extension takes toplevel and HEAD from one git rev-parse --show-toplevel --verify -q HEAD and the remote from one git remote get-url origin (3 s each, fail open: null) |
input |
text (≤16 KB), source, idle (bool), after_deliverable (last assistant text ≤2 KB or null), prelabel ∈ {"none","interrupt","revert","reprompt","negation","mark:<class>"}, skills_invoked [names from /skill: tokens] |
tool_result |
`tool, call_id, input (JSON ≤8 KB, keys kept, long strings truncated with “…[+N]”), args_hash (sha256 of canonical JSON of full input), result_bytes, is_error, error_class (null |
tool_blocked |
tool, call_id, hook_id, reason (a block or stuck hook); args_hash when written by the Claude Code adapter, whose stuck window is read back from the trace |
turn_end |
model {provider,id}, usage {input,output,cacheRead,cacheWrite,totalTokens,cost}, stop_reason, text_head (≤2 KB) |
agent_end |
turns, is_terminal, yielded |
model_change |
from {provider,id} , to {provider,id}, reason (from retry_fallback_applied or turn-to-turn change) |
mark |
`class ∈ correction |
retrieval |
entry_ids [retrieve hook ids], units [{entry_id, path (relative to the entry dir), bytes}], bytes — written when a retrieve hook appended a different unit set; trace::fold_trace adds entry_ids and every units[].entry_id to the session’s loaded_entries, so counting attributes the added context to the hook and to the packages it came from |
session_shutdown |
reasons [...], flagged (bool), files_changed [[path, sha256]...] — the files successful edit/write/multiedit/notebookedit/apply_patch/ast_edit calls named (path/file_path/paths, and the paths inside an edit or patch payload), hashed at shutdown: the first 50 readable files ≤ 1 MB, repo-relative inside the repo; omitted when hashing overruns its budget (extension 1 s of the 2 s shutdown budget, Claude Code SessionEnd 500 ms) or nothing changed (trace::changed_paths, trace::hash_changed_files) |
Pre-classification (extension, no model; regex constants live in rematch-core/src/redirect.rs and are copied verbatim into the extension): mark = /^\/rematch\s+mark\s+(correction|preference|scope)\b/i (the Claude Code adapter first spells its /rematch-mark <class> and /rematch:rematch-mark <class> as /rematch mark <class>, §18.1); interrupt = input while the agent loop is running (between agent_start and agent_end with willContinue; ctx.isIdle() is also false while a finished turn settles and over-counted takeovers in live use; the Claude Code adapter: a prompt delivered into a run that had no Stop and no Esc abort since its input, §18.1); revert = /\b(?:revert|undo|roll ?back|put it back|git restore)\b|\bgit checkout --(?:\s|$)/i; negation = /^(?:no|nope|don't|do not|stop|not that|wrong|that's (?:not|wrong)|that is wrong|this is wrong|incorrect|you didn't|you did not|not what i|i said|actually|instead)\b/i (leading whitespace trimmed); reprompt = Jaccard(tokens(text), tokens(previous input)) ≥ 0.6 with tokens [a-z0-9]+ on lowercased text and at least 6 tokens in the new input (“four” after “three” is not a redo). Precedence: mark > interrupt > revert > negation > reprompt > none.
Session is flagged when any of: ≥1 prelabel ≠ none; is_error tool results ≥ 3 in any window of 6 consecutive tool results (tool-error cluster); a mark (trace::flag_reasons). Flag = write pending/<session_id>.json.
Imported sessions get the same trace after the fact (rematch_core::import::trace_records_of, the active branch of the omp JSONL): session_start (header id/cwd, repo by walking up to .git, model of the first assistant message, mode: "import", agent main or sub for subagent files, omp_version from runtime.version, no loaded entries/hooks/staging dirs); each user message not attributed to an agent → input with the rules above (idle unless the previous assistant stopped aborted; slash/!/% lines other than mark and /skill: dropped as live), preceded by agent_end for the run it ends; each toolResult → tool_result paired with its toolCall by id; each assistant message → turn_end after its tool results, preceded by model_change when provider/model changed; then agent_end and session_shutdown with the flag reasons. Not recoverable from omp JSONL: input.source (empty), branch_hint beyond the repo basename, loaded_entries/hooks_loaded/staging_dirs (empty), tool_blocked, mark, retry-fallback model_change reasons, agent_end.is_terminal (always true). Redirect and error-cluster seqs of an imported ledger row address this synthesized trace.
Claude Code and Codex sessions get the same schema: live from the Claude Code plugin’s hooks (trace-event, §18.1) and after the fact from their transcripts (rematch_core::import_claude::trace_records_of, import_codex::trace_records_of, §18).
No trace record carries the session’s domain (§6 “Domain”): session_start stays as above in the extension and both adapters. The binary decides it from the folded record — count (and reflect, create --from task, activate) when it folds a trace, the three importers when they import — so the policy, the branch’s domain.yml and the inference table in force at that moment apply, and lake migrate re-decides it for recorded sessions. Load time knows only the place: match reports the place’s declared domain as domain (§2), which a harness may read but need not echo.
Identity fields by writer (ADR 001): the Claude Code SessionStart fills repo_remote/subpath/head_sha by file reads (identity::identify: .git dir or worktree gitdir: file, the common dir’s config, HEAD and refs, no git process), versioned loaded_entries from match, visible_entries from its sync, and SessionEnd writes files_changed. The three importers fill repo_remote and subpath from the repo on disk at import time (identity::session_place), never head_sha or files_changed (the import runs after the fact); Codex records its offered rematch skills as visible_entries and the loaded ones with version and sha from their rematch.json. count fills a missing repo_remote/subpath from the repo on disk (identity::fill_session), e.g. an extension trace of a repo without origin.
4. Core types (rematch-core/src/types.rs, authoritative in Rust)
Section titled “4. Core types (rematch-core/src/types.rs, authoritative in Rust)”pub enum Kind { Skill, Rule, Script, Agent, Hook, Fact, Lesson }pub enum Status { Draft, Provisional, Kept, Stale, Dormant, Retired, Pinned }pub enum Scope { Personal, Team, Fork }pub enum Origin { Session{session_id}, Task{task_id}, Import{source}, Description }pub struct Family(String) // "anthropic/claude", "github-copilot/gemini", … (provider + model stem, see family())pub struct Predicate { repo_glob: String, file_exts: Vec<String>, stems: Vec<String> /* ≤10 */, repo_remote: Option<String>, subpath: Option<String>, domain: Option<String> /* §6 "Domain" */ }// SessionRecord (the ledger row) also carries `domain: Option<String>`.pub struct Moment { session_id: String, seq: u32 }pub struct Manifest { evidence: Moment, predicted_fix: String, predicted_regression: String, rationale: String, scope: EditorScope }pub enum EditorScope { Domain, Invariant }pub struct Entry { id, kind, name, branch, scope, status, family: Family, verified_on: Vec<Family>, helpful: u32, harmful: u32, evidence: Vec<Moment>, origin, predicate, trigger_line: String, redirect_class: Option<RedirectClass>, predecessor: Option<EntryId>, version: u32, path: PathBuf, created_at, updated_at, manifest: Option<Manifest>, lesson_tag: Option<LessonTag /* Invariant|Domain */>, pinned: bool, content_sha256: Option<String>, last_tune_at: Option<UnixMs> /* §6 "Predicate tuning" */, tune: Option<PredicateTune>, approved_scripts: Vec<String>, approved_fragment: Option<String> /* sha256 of AGENTS.fragment.md at approval */, approved_agent: Option<String> /* sha256 of AGENT.md without its `rematch:` frontmatter block, at approval */ }pub struct PredicateTune { parent: EntryId, parent_version: u32, at: UnixMs, direction /* widen|narrow|domain */, from: Predicate, added, removed, added_exts: Vec<String>, domain_set: Option<String>, evidence: TuneEvidence { read_missed, matched_unread, used: Vec<session id>, source /* lake|ledger */, window_ms } }pub struct Counters { matched: u32, recurrence: u32, harm: u32, tokens: u64, turns: u32, per_family: BTreeMap<Family, FamilyCounters> }pub enum Decision { Keep, Retire, Dormant, Continue }pub struct Proposal { edits: Vec<Edit>, manifest: Manifest, cites: Vec<String /* sha256 */> } // no score fieldpub enum Edit { Write{path, content}, Delete{path} } // paths relative to entry dirpub struct LintReport { ok: bool, findings: Vec<LintFinding{rule, severity: Error|Warn, message, path}> }pub struct Certificate { lint: LintReport, canary: Option<Paired>, verifier: Option<Verdict> }pub struct Receipt { certificate: String /* sha256 */, entry: EntryId, activation: Activation /* Personal|Prompted|Pr */ }pub enum RedirectClass { Correction, Preference, Scope, None }pub struct Outcome { … exactly SPEC §"Session outcome records" … }Entry directory layout (<dir> = staging/drafts/archive/kept path):
<dir>/SKILL.md frontmatter: name, description, rematch: {id, status, branch, family, version, predicate_hash}<dir>/AGENT.md kind=Agent instead of SKILL.md: frontmatter name (= entry name), description, optional model/tools/…, rematch: {…}; body = the subagent's system prompt<dir>/rematch.json serde of Entry + manifest + predicate (the ledger row mirrors it; rematch.json is authoritative for files, ledger for counters)<dir>/units/*.md facts/*.md (each fact has `as_of:`) scripts/* (mode 0644 until approved; 0755 after)<dir>/hook.json kind=Hook only: HookRule {mode (block|truncate|stuck|retrieve, default block), tool (`*` = any), field + regex (block), message, pass_condition, max_age_turns + keep_chars (truncate), repeats (stuck), budget_bytes + min_overlap (retrieve)}; unset mode parameters take the defaults 6, 1200, 3, 6000, 2 (`HookRule::params`)<dir>/AGENTS.fragment.md optional: system-prompt text. Personal: loads only after approval (`library approve`, bound to its sha256 in `Entry.approved_fragment`, so an edited file asks again) and is appended as `## rematch: <name>` to the system prompt of main sessions where the entry loads (§16 "System prompt"); never written to the user's AGENTS.md. `<repo>/AGENTS.md` only through `ship --scope team`<dir>/fork/<basename> optional: the text `ship --scope fork` writes to `<fork>/<fork_path>`<dir>/EXPERIENCE.md written by the reflector after keep/retire (read-only to the system)5. Ledger (SQLite, rusqlite bundled)
Section titled “5. Ledger (SQLite, rusqlite bundled)”Tables (all with created_at ms): schema_version; sessions(id PK, cwd, repo, branch, started_at, ended_at, family, provider, model, turns, tokens_in, tokens_out, tool_calls, tool_errors, skill_reads JSON, files_touched JSON, exts JSON, stems JSON, redirects JSON, flagged, counted, source 'trace'|'import'); redirects(id, session_id, seq, prelabel, class, confidence, text_head, deliverable_head); entries(id PK, json) + entry_status(id, status, branch, name, predicate_hash, kind, family); counters(entry_id, session_id, family, matched, recurrence, harm, tokens, turns, PRIMARY KEY(entry_id, session_id)); proposals(id, entry_id, session_id, blob, lint_ok, created_at); receipts(id, entry_id, certificate_hash, activation); admissions(predicate_hash, day, entry_id); jobs(id, kind, status, json); outcomes(id, session_id, ts, machine, family, repo, branch, kind, domain, subject_json, claim, signature, params_json, evidence_json, result_json, links_json, provenance_json, status) with indexes exact(signature), composite via outcome_facets(outcome_id, domain, key, value) indexed (domain,key,value), temporal(subject_id, ts); minhash(outcome_id, band, hash); journal(seq, known, machine, table_name, row_key, op, json), one row per write to sessions, redirects, entries, counters, proposals, receipts and outcomes (ADR 001), and meta(key, value) holding the ledger’s machine (§13).
6. Predicate semantics (deterministic, same everywhere)
Section titled “6. Predicate semantics (deterministic, same everywhere)”Derivation (binary, on stage): repo_glob = the originating session’s repo path (exact, as recorded by the extension from git rev-parse --show-toplevel) or ** if no repo; file_exts = extensions of files read/edited in the evidence window (≤8); stems = up to 10 Porter-stemmed, stop-worded, lowercase tokens ranked by tf-idf against the ledger’s session corpus over a weighted document: the originating task turn (the last user input before the redirect) ×2 + basenames of touched files ×2 + the redirect turn ×1. The redirect’s own wording (“that’s wrong”, “every”) must not crowd out the task’s domain terms, or later sessions on the same task never count (observed on the first real run; fixed in reflect.rs). The editor’s entry.stems are used only when derivation yields nothing: the pre-editor predicate hash is what the rate limits key on.
Place (ADR 001, predicate::matches_place): a predicate carrying repo_remote, at a place that has one too, matches when the remotes are equal and its subpath glob (None, empty or ** = the whole repo) matches the toplevel-relative cwd or one of its ancestors; when either side has no remote the repo glob decides as below. So a worktree or another clone of the same remote matches its repo’s entries wherever it lives, and a fork (another remote) does not. repo_remote = identity::normalize_remote of origin, else of the first remote: host/org/repo, scheme, user, port, .git and trailing / stripped, host lowercased, path lowercased on github.com and gitlab.com; a repo without a remote is local:<sha256 of the main worktree's toplevel> (linked worktrees share it). Derivation (predicate::derive) fills repo_remote from the repo on disk and subpath = **, keeping repo_glob as the fallback; predicate_hash leaves both out (they restate the glob’s repo). The place of match is Place::locate(repo, cwd) (file reads), of a recorded session Place::of_session (its repo_remote/subpath).
Load-time match (match --cwd): the place matches (glob against repo, else cwd, when there is no remote) ⇒ branch dir contributed when ≥1 provisional non-agent, active entry in that branch matches. A kind: hook entry is active only while its approved module rematch-hooks/<id>.ts exists (library::hook_active): before approval it contributes no dir and is listed with predicate_hit: false, so it never counts. Provisional agents are listed in every session once approved (library::agent_approved; omp discovers them from the plugin root regardless of the cwd) with predicate_hit = their glob hit, so the trace’s loaded_entries carries them only where they would count; an unapproved provisional agent is not listed. prompt_fragments holds {id, name, text} for every listed provisional/kept/pinned entry with predicate_hit whose AGENTS.fragment.md is present and approved (library::fragment_approved: the stored sha256 equals the file’s), ordered by name; the total text is capped at the lint always_on_bytes and extra fragments are dropped. Both the given path and its canonicalize()d form are tried (macOS /tmp → /private/tmp), on either side.
Triage (trace::triage) has two independent axes: kind process|content decides whether to propose (content = a corrected prose answer with no tool activity and no process words; anything the agent produced with tools, tool-error clusters, takeovers, reverts and procedural text are process); scope domain|invariant picks the editor (domain by default; invariant for error clusters, takeovers, reverts, or text about the agent’s general way of working, INVARIANT_TEXT_PATTERN). After classification, a session whose only redirects are scope|none and that has no cluster/takeover/mark carries no signal: no editor call.
Count-time match (binary, from a finished trace): place hit (above) AND (file_exts empty OR overlap ≥1) AND (stems empty OR overlap ≥ min(2, stems.len())). Inputs = first 5 input texts + all tool_result.path values. An entry whose version session_start recorded counts only at that version (counters::fold_session matches (id, version); retrieval credit stays by id).
Domain (rematch_core::domain, no model): a dotted lowercase path (web, web.frontend, quant.options, 3d.animation) on sessions (SessionRecord.domain) and predicates (Predicate.domain). Decided by domain_of(repo_remote, subpath, branch, exts, stems, &DomainPolicy), first rule that answers: (1) policy.json domains: [{match: {repo_remote?, subpath?}, domain}], first match wins (repo_remote equal to the session’s or a glob over it, subpath a glob over the toplevel-relative cwd or an ancestor; an absent field matches anything; rematch policy domain set keeps them most specific first: a subpath, then an exact remote); (2) the session’s branch (library::branch_for) branches/<branch>/domain.yml domain:; (3) inference from improver/domain_rules.toml (user-editable, shipped like the other improver files): per top-level domain, score = 2 × distinct session extensions in its [[ext]] rows + 1 × distinct session stems in its [[stem]] rows (table words compared as written and Porter-stemmed); the best wins with score ≥ 2 and a margin ≥ 1 over the runner-up, else None; a second level comes only from a [[stem]] row of the winner whose stems hit (the most hits, table order on ties). The shipped table: ext tsx jsx css html vue svelte → web, swift kt m dart → mobile, blend fbx gltf usd anim → 3d, parquet sql ipynb csv → data, rs go c cpp java → backend; stems backtest sharpe nbbo option strategi portfolio → quant, anim rig bake skeleton keyframe → 3d.animation, ui component css layout a11y → web.frontend, ios android swiftui compose → mobile. Gate (domain::domain_matches, inside matches_place, so load- and count-time): when both the predicate and the place carry a domain, the place’s equals the predicate’s or extends it at a dot (quant matches quant.options, never quantx; quant.options does not match quant); absent on either side, nothing is tested, so pre-domain entries and undecided sessions behave as before. Count time uses the session’s recorded domain; load time (match, skills sync) only the place’s declared domain (rules 1–2: exts and stems are unknown then), and match --inputs the full rule. Derivation fills domain from the originating session (reflect, create --from task, batch winners from the reference rollout); create --from sessions and improve take the reference sessions’ majority (majority_domain: a strict majority of those with one on the full domain, else on the top level, else none); create --from description decides it for the cwd’s place with the description’s exts and stems; transfer apply gives the copy the target branch’s domain.yml domain, a shared promotion the common_domain of the branches’ copies (all equal ⇒ it, one top level ⇒ that, else none), an export import keeps the entry’s own. predicate_hash includes domain when present (absent: the pre-domain hash), so a web and a mobile trigger with the same content are two triggers. Declaring: rematch policy domain set <remote|glob|repo path> <domain> [--subpath <glob>] (the only writer of policy.json); lake migrate re-decides every recorded session with the current policy and gives an entry predicate its originating session’s domain when that session has one (idempotent). The digest proposes, never applies, one line per undeclared remote where ≥ 5 sessions read skills whose entries share one domain (common_domain of the read entries’ predicates per session, votes by top level, no tie): declare domain <d> for <remote>: rematch policy domain set <remote> <d>.
Predicate tuning (crates/rematch/src/tune.rs, no model; rematch tune, and inside count after its decisions whenever it folded a session). A predicate learns from which sessions read its entry, under the same counters.
- Signals, per entry over a window (default 6w), from the lake when it holds every journal row this ledger wrote, else from the ledger’s journal (the
sessionsrowsreportreads, each its latest row):- read-but-missed: sessions where
skill_readscontains the entry’s name (theevents.skill_readkey) or aretrievalrecord credited one of its units (checked only for packages, from the session’s events file or trace), AND the entry was visible (visible_entries; or loaded; or it is a user’s skill registered in place, which loads natively everywhere), AND the predicate did not hit (count_matchfalse). The model opened it; the stems missed. A read whose session domain the entry’s domain does not cover is listed ascross_domain_reads, and a read at a place the predicate does not cover (repo glob, remote, subpath) ascross_place_reads; neither is evidence: a tune never widens a domain or a place. - matched-but-unread: sessions where the predicate hit and the entry was loaded, but no
skill_read/retrieval of it occurred. (Hit, loaded and read =matched_read.) - Loaded/visible test an entry id on its own machine; a session of another machine loaded it by
content_sha256.
- read-but-missed: sessions where
- Proposer (no model): read-but-missed ≥ 3 → new stems = current ∪ (task-turn stems of those sessions present in ≥ 2 of them), ranked by tf-idf (
features::rank_stems, tf = sessions carrying the stem) against the window’s sessions, capped at 10 (current stems first);file_exts= current ∪ observed (cap 8; an empty list stays empty — it already matches every extension). Matched-but-unread ≥ 4 and read-but-missed < 3 → drop stems that occur in the unread sessions’ stems but in none of the read sessions’ (most unread sessions first; keep ≥ 2 stems; never empty). Domain: an entry without one whose read sessions carry exactly one domain, in ≥ 3 of them, gets it (a narrowing); an existing domain never changes; stems are never unioned across domains (the widening uses only the sessions the target domain covers; with no target and read-but-missed sessions in two domains, no widening). No change → no proposal. - Guards (
tune::check, all evaluated by--dry-runtoo): a sibling entry (same files,version+1,sibling_of,status_note: "predicate tune: +[..] -[..] from N session(s)"— added stems,.exts anddomain=<d>after+—, originImport{source:"predicate-tune"}, status provisional,tune= the change and its evidence) — never an in-place edit; at most one tune per entry per 7 days (last_tune_aton the entry) and one pending tune sibling per entry; never on pinned/built-in/hook entries, nor on anything but a provisional or kept skill (or rule) entry; the sibling’s staging slot (staging/<branch>/<name>) must be free, so a provisional rematch-owned parent is tuned only after its own canary keeps it (one pending version per name); never removerepo_remote/repo_glob/subpath; never produce**; stems ≤ 10, exts ≤ 8, stems ≥ 2 after a narrowing; lint and rate limits apply unchanged (new predicate hash = new canary; a kept-capNeedsMergedoes not block, since a tune revises an existing entry, which is what that signal asks for). The tune is itself counted and can be retired, and keeps/replaces v1 through the existing anchor path (§7: itssibling_ofnames the parent; the keep takes the parent’s kept slot). Every tune writes its evidence (session ids per signal) into the proposal row ({decision: "PREDICATE_TUNE", entry, tune, from, to}) andrematch.json, records an admission on the new hash, and sets the parent’slast_tune_at; nothing else of the parent changes. - Reading it back:
library whyshowspredicate tune v2 pending (<id> · <note> · matched … recurrence … harm …)under the parent’s line while the sibling is provisional;reportlists every tune in the window (§19.3).
content_sha256 (entry identity across machines): sha256 of {"files":[[path, sha256]…]} over the entry dir’s files sorted by /-separated path, without rematch.json, EXPERIENCE.md and hidden files, with the rematch: frontmatter block removed from SKILL.md / AGENT.md (library::content_sha256); set at stage, at keep (also a ledger-only keep of a foreign entry) and when improve or import --forge registers an existing skill or lesson; rematch lake migrate fills it on older entries.
7. Counters and decisions (SPEC §loop 7–8, verbatim)
Section titled “7. Counters and decisions (SPEC §loop 7–8, verbatim)”Per matched session (predicate hit while the entry was in loaded_entries): recurrence += 1 if a redirect of the entry’s redirect_class occurred (classifier output, or prelabel ∈ {revert, negation, mark} when class unknown); harm += 1 if a new tool-error cluster, an interrupt, or a user disable happened. Tokens/turns recorded.
Decide: matched ≥ 4 && recurrence == 0 && harm == 0 → Keep; recurrence ≥ 2 || harm ≥ 2 → Retire; matched ≥ 12 → Dormant; provisional for 30 days && matched < 3 → Dormant (for an entry with counter rows; one that never matched has none and takes the sweep below); Kept && harm ≥ 2 within 30 days → Retire; else Continue. Per-family breakdown kept; if one family shows harm ≥ 1 and another has matched ≥ 2 with zero harm/recurrence, the entry is split (Phase 3): the harmed family gets a sibling proposal. Never-matched provisional entries have no counter row, so the rules above never see them: count (so reflect --pending too) sweeps, right after its per-entry decisions and on every run, the provisional, unpinned entries that got no counter row (lessons and built-ins exempt) and makes one Dormant once 30 days have passed since status_since and ≥ 12 counted sessions in its scope (its branch; every branch for shared) finished since then, with matched < 3. Idle time alone never retires an entry.
Implementation notes (counters.rs): each matched session contributes 0/1 recurrence and 0/1 harm (one row per (entry, session)). Prelabel mapping when the classifier gave no class: revert|negation|mark → correction, mark:<class> → that class; interrupt|reprompt|none → no class; a classifier none means no class. An entry without a redirect_class (motivated by an error cluster or takeover, and the built-ins of §10) recurs on a correction or preference redirect; a scope redirect (the user widened or changed the task, which trace::triage also treats as carrying no failure signal) never counts as its recurrence. Harm = error_clusters > 0 || takeovers > 0 || disabled. Rule order: pinned ⇒ Continue; provisional: Retire, Keep, Dormant(12), Dormant(30 d since status_since, < 3 matched); kept: ≥ 2 harm events in the last 30 days and since it became kept ⇒ Retire. Count-time match uses repo (else cwd) against the glob, where a glob also matches descendants of the path it names. A retrieve hook is in a session’s loaded_entries only through a retrieval record (§3), i.e. only in sessions where it injected units; session_start.loaded_entries never lists it.
Anchor replay (SPEC §Rollback and anchors; research D17, HCL b=1; scope/anchor.rs): a Keep of an entry that replaces a kept entry — its sibling_of names a kept entry, or keeping it takes the kept slot (<skills>/<name>/, agents kept-agents/<name>/) a kept entry holds, which library::set_status would supersede (rematch-owned entries only) — first replays the kept entry’s originating task once. Anchor task, first with a verifier: the kept entry’s Origin::Task, the replacing entry’s, then a task in the kept entry’s branch (any task for shared) whose last runs/<task>/receipt.json lists the kept entry (staged[].id, staged[].merged_into, next_run[].id, or the reference rollout’s loaded); verifier = the task’s own, else its last run’s job_spec.verifier. The replay is one rollout (batch::rollout::run_rollout, label anchor-<short id>, the task’s first seed, solver role) under the task’s run lock: base library for the task repo minus both entries, the replacing entry’s files as the only candidate. Pass ⇒ the keep proceeds and the replacing entry gets a receipt Certificate{lint, canary: its counters, verifier: <verdict>}. Fail ⇒ Retire with status_note anchor replay failed: task <id> (<verifier>): <detail>; the kept entry stays; the reflector input carries anchor_replay. No anchor task with a verifier ⇒ NoVerifier: the counters (the canary) are the anchor and the keep proceeds. A replay error (task locked by a running run, omp missing) fails the decision; the entry stays provisional and is decided again at its next matched session. Every replay record (task, verifier, verdict, rollout summary) goes to the blob store; the count envelope’s decision carries anchor: {kept, replay, certificate?}. There is no anchor suite.
8. Rate limits
Section titled “8. Rate limits”Per predicate_hash: ≤1 admission per UTC day; no reflection while a provisional entry with the same predicate_hash exists (pending canary). Per branch: ≤10 provisional; 5 kept general + 5 kept per branch before a new candidate must merge/revise (near-duplicate by simhash(normalized text) Hamming ≤ 6 or identical script body ⇒ append evidence to existing entry). Per top-level domain (§6 “Domain”): ≤ 8 kept entries across every branch on this machine (policy.json caps.kept_per_domain, rematch policy caps set kept_per_domain <n>). ≤3 model calls per reflection (classifier batch, editor, one lint-retry).
Implementation (rate::can_admit(ledger, predicate_hash, branch, domain, kept_per_domain, now), checks in this order): pending canary (any provisional entry with the predicate_hash) → PendingCanary; an admission today (UTC) → DailyPredicate; ≥ 10 provisional in the branch → BranchProvisionalCap; kept cap → NeedsMerge {branch, general, branch_kept, domain, domain_kept} (for branch shared: ≥ 5 kept in shared; otherwise ≥ 5 kept in the branch; or, when the candidate’s predicate has a domain, ≥ kept_per_domain kept non-built-in entries whose predicate domain has the same top level, any branch). NeedsMerge is a routing signal: the caller merges/revises via dedup::route_duplicate instead of dropping the candidate. Built-in entries (§10) are neither canaries nor counted against the caps.
9. Editor channel (omp -p)
Section titled “9. Editor channel (omp -p)”argv (forge contract): omp --cwd <tmp> --model <provider/id> --no-tools --system-prompt <improver/editor.md> --config <improver/omp-no-tools.yml> --no-prewalk --no-session --no-title --no-extensions --no-skills --no-rules --no-lsp --mode json -p -- <prompt>; env adds PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0; own process group; timeout (default 180 s) ⇒ SIGTERM group, 2 s, SIGKILL group. Every assistant message must match provider+model (and upstream* when present); any mismatch, errorMessage, stopReason ∈ {error, aborted}, or toolCall block ⇒ failed run (never a fallback).
--editor <cmd> (tests) replaces omp: the command receives the prompt on stdin and prints the reply.
Editor prompt input (one JSON document in the user prompt, wrapped as data):
{"scope":"domain|invariant","trace_excerpt":[...trace records around the moment...],"deliverable":"...","redirect":{"class":"...","text":"..."}, "current_entry":{"files":{"SKILL.md":"..."}}|null,"library":[{"name","trigger_line","experience":"..."}],"lint":{"rules":[...]},"retry_findings":[...]|null}Editor reply: one fenced ```json block:
{"decision":"EDIT|KEEP_BASE","entry":{"kind":"skill|hook|script|fact|lesson|agent","name":"kebab","description":"...","trigger_line":"Use when ...", "files":{"SKILL.md":"...","units/x.md":"...","scripts/x.sh":"...","AGENTS.fragment.md":"..."},"hook":{...}|null,"stems":[...],"fork_path":"src/…"?}, "manifest":{"evidence":{"session_id":"...","seq":0},"predicted_fix":"...","predicted_regression":"...","rationale":"..."}}Classifier prompt: batched [{id, text, after_deliverable, prelabel}] → json `[{"id":..,"class":"correction|preference|scope|none","confidence":0-1}].
10. Extension (extension/index.ts, no build step)
Section titled “10. Extension (extension/index.ts, no build step)”Factory (async): read env/off; resolve binary ($REMATCH_BIN → <ext>/../target/release/rematch → <ext>/../target/debug/rematch → PATH); pi.exec(bin, ["match","--cwd",cwd]) and pi.exec(bin, ["skills","sync","--cwd",cwd,"--harness","omp"]) in parallel (5 s each); override skills.customDirectories with [...configured, ...dirs] (dirs of the sync = the shared REMATCH_ROOT/skills, §16 “Shared skills”); remember entries/hooks of the match (a failed sync leaves the hooks adopted, a failed match leaves the skills added); register tool rematch {operation, arguments[]} (approval: read for doctor/status/library/digest/match/why, builtins ls, transfer suggest, exec otherwise), command /rematch, shortcut alt+e (digest), handlers §3. session_start: open trace, write session_start (staging_dirs = the skill dirs added), spawn reflect --pending detached (Bun.spawn with stdio ignore, unref), run digest and present lines via ctx.ui.notify, and each approval via ctx.ui.confirm(title, diff) → library approve|decline <id>. /rematch <sub>: why|status|library|create|improve|run|activate|mark|off|on|disable|rollback|approve|ship|transfer|builtins|anchor map 1:1 onto the binary; long ones return a job id. Re-match. The loaded set (entries, hook modules, added skill dirs) is matched and synced again — and the hook sets swapped atomically, serialized with any in-flight match — when a main session’s ctx.cwd differs from the cwd last matched (at session_start and on the next context event), on session_switch/session_branch, and after a successful library approve|disable|rollback or activate (digest approvals included), so an approved hook runs from the next tool call or request, not the next omp start; skill links a re-sync adds reach omp’s skill list at its next start (discovery ran once, after the factory). The module-level match + skills sync cache (shared by subagent rebinds, keyed by binary + cwd) is cleared on a switch/branch and after those operations, so no cached result outlives the session it was matched for; a failed match or sync is not cached and leaves the previous set in place. A session’s session_start.loaded_entries is what was loaded when it started: an entry approved mid-session runs at once and counts from the next session. Hook modules are imported as <path>?v=<mtimeMs> so a rewritten module is a new module record (Bun ignores the query of a file: URL for its module cache, so the path is used as is).
Profile sync at session start (§20). match reports profile_present (REMATCH_ROOT/profile/.git exists). The first main session_start of a process whose match saw a profile starts pi.exec(bin, ["profile","sync","--timeout","8"], {timeout: 10 s}) in the background (not awaited; once per process; no exec at all without a profile); a failure or offline: true is logged and the session goes on. What it pulls loads in the next session (omp scanned its skills at process start).
Personal system prompt. match reports prompt_fragments (the approved AGENTS.fragment.md of the entries loaded for the cwd, §2 match); the extension keeps them with the match. Its before_agent_start handler (main sessions only, nothing for reason: "revive", a cwd change re-matches first) returns the event’s systemPrompt with one block appended — an array element on omp 18.13, a string joined by a blank line on older hosts — and keeps no state, so a re-entry appends once. A block is ## rematch: <name>, a blank line, the trimmed fragment (the heading ship::upsert_agents_section uses); blocks are joined by a blank line. Off or no fragment: the prompt is returned unchanged, and any failure fails open. The user’s AGENTS.md is never written.
Hook modes. A compiled hook module (library::hook_module) is data only: export default { id, mode, tool, field, regex, message, pass_condition, params }, params = the resolved mode parameters (HookRule::params; for block the JS regex {source, flags}, a leading Rust (?i) becoming the i flag). The extension validates the shape (hookFromModule; an invalid module is skipped with a warning) and implements the modes; tool: "*" matches every tool.
| mode | event | behavior | on failure |
|---|---|---|---|
block |
tool_call |
input[field] (a string) matches the regex ⇒ block with message, trace tool_blocked |
closed: a throwing check blocks with rematch hook <id> failed: … |
stuck |
tool_call |
the extension keeps the session’s last 32 calls as tool\0args_hash (sha256 of canonical JSON, as in the trace); a call whose repeats immediately preceding calls are identical to it is blocked with message (trace tool_blocked). Any different call in between resets the run, so re-running a command after an edit passes; a call a block rule rejected never ran and is not recorded (one stuck rejected is, so the next identical call is rejected too). Reset on every session start |
closed |
truncate |
context |
walking back from the newest message, age = non-synthetic user messages after a toolResult; when age > max_age_turns, the result’s text blocks become one block with the first keep_chars UTF-16 code units (one fewer when the cut would split a surrogate pair) + \n…[rematch: truncated N chars, age K turns] (N = code units removed); image blocks stay; several rules ⇒ the smallest keep_chars. A fresh object replaces the message in the outgoing array only |
open |
retrieve |
context |
main sessions with a trace only. Query = the last non-synthetic user message text + the paths (arguments.path/file_path, selectors stripped) of the toolCalls behind the last 10 toolResults, ≤ 16 KB. pi.exec(bin, ["retrieve","--cwd",ctx.cwd,"--budget",B,"--min-overlap",M,"--query",q], {timeout: 3000}) — no --entries: the binary already bounds candidates to the entries loaded for --cwd; the result is cached per argv. When units come back, ONE message is appended: {role:"custom", customType:"com.vncp.rematch.retrieval", display:false, timestamp, content, details:{units:[{entry_id,path}]}} with content [rematch] Units of skills loaded in this session that match the current request, retrieved by the harness (the whole skill: skill://<name>). followed by one <unit skill="<name>" path="<path>">…</unit> per unit; omp sends it as a developer message (§0). The same message object (same timestamp) is re-sent while the unit contents are unchanged. A changed unit set writes one retrieval trace record, the only way the hook enters loaded_entries (§7). Several retrieve hooks ⇒ the first one’s parameters, every one’s id in entry_ids |
open: a failed or slow call adds nothing |
truncate and stuck also run in subagent sessions (like block); retrieve needs the main trace to attribute what it added. Handlers never throw; the context handler returns undefined when it changed nothing.
Built-in entries (crates/rematch/src/builtins.rs; SPEC §Isolation, research D22/D23). The context policies are shipped as three ordinary hook entries, not as always-on extension behavior: branch shared, repo glob ** with no exts/stems (they match every session), Origin::Import{source:"builtin"}, family unknown/unknown, each with a SKILL.md router (trigger line, laws, pitfalls) and hook.json:
{"mode":"retrieve","tool":"*","message":"rematch: units of the loaded skill packages that match this request.","pass_condition":"A request that shares no stem with a loaded package's trigger line, or whose loaded packages hold no unit sharing 2 distinct stems with it, passes unchanged; units only ever come from entries this session loaded, and at most 6000 bytes are added.","budget_bytes":6000,"min_overlap":2}{"mode":"truncate","tool":"*","message":"rematch: old tool outputs trimmed in the outgoing request.","pass_condition":"Tool results at most 6 user turns old, and any result of at most 1200 characters, reach the model unchanged; the session file always keeps the full output.","max_age_turns":6,"keep_chars":1200}{"mode":"stuck","tool":"*","message":"rematch stuck-detector: this exact call (same tool, same arguments) already ran 3 times in a row. Change the approach: different arguments, a different tool, or ask the user.","pass_condition":"Every call that differs from the call immediately before it passes, and so do the first 3 repetitions of any call; re-running a command after any other call (an edit, a read) passes.","repeats":3}(context-unit-retrieval [D22], tool-output-truncation [D23], stuck-detector [D23]). builtins::ensure runs from AppCtx::open_with after the improver install: one read of REMATCH_ROOT/builtins.version (hash of the shipped specs) when nothing changed; otherwise, under .builtins.lock, it stages every built-in whose name has no built-in entry yet and rewrites the marker. A failure is logged to stderr and never fails the command. A retired, declined or dormant built-in is never re-staged automatically; rematch builtins reinstall [name…] stages a fresh provisional version (version + 1, predecessor) of each built-in that is not live, and builtins ls lists them with status, approval and counters. They arrive provisional and unapproved: the digest offers each as a hook approval (the module text as diff, reason = trigger line + pass condition). An unapproved hook entry is inert in full (library::hook_active): it contributes no branch dir, match reports predicate_hit: false (so it never enters loaded_entries), and no module exists. Approved, a built-in counts like any entry. It carries redirect_class: None on purpose: it was motivated by no particular redirect, so every correction or preference redirect in a session where it ran counts as recurrence, and error clusters, takeovers or a disable count as harm; two of either retire it (§7). A scope redirect does not count: the user widening or changing the task says nothing about a context policy. That makes a context policy prove itself against everything the user corrects while it is active. context-unit-retrieval is credited only where it acted: the extension leaves retrieve-mode hooks out of session_start.loaded_entries, and the hook enters a session’s loaded_entries through the retrieval record (§3) written when it injected units. Built-ins take no admission-cap slot and are no pending canary (rate::can_admit skips library::is_builtin), and ship --scope team never selects them automatically.
11. Deviations from SPEC (all forced by §0)
Section titled “11. Deviations from SPEC (all forced by §0)”resources_discover→ factory-timeskills.customDirectoriesoverride (same semantics, per process).- Batch isolation uses a
--configoverlay + fresh worktree instead of a privatePI_CODING_AGENT_DIR(auth lives in the agent dir). omp plugin install @<org>/rematch→scripts/install.sh(cargo build --release,omp plugin link extension/, PATH shim). No registry publish.- “Related thing: embeddings, exploratory only” is not implemented: the binary holds no provider credentials; exact signature + MinHash are implemented.
- Status
pinnedis carried as theEntry.pinnedflag (status and location unchanged; automatic transitions refuse pinned entries;disable/rollbackare user actions and proceed).Status::Pinnedremains a valid status for imports. - A kept entry replaced by a sibling becomes
dormant(files inpredecessors/<name>/<version>/) rather thanretired, becauserollbackmust be able to restore it andretirednever leaves. A non-rematch skill that a kept entry replaced is saved as version0and restored byrollback/uninstall. - Declining an approval (
library decline) retires the entry (SPEC: retired ← “approval declined”). - Provisional agents cannot load “on match”: omp discovers task agents only from fixed dirs (§0), so once approved (
library approve, digest kindagent) they load through the plugin root in every session and are counted where their glob hits; an unapproved one is linked nowhere.offdoes not unload them (omp reads the plugin’sagents/itself);library disableoromp plugin disabledoes.
12. lint.toml schema
Section titled “12. lint.toml schema”improver/lint.toml (copied to REMATCH_ROOT/improver/ on first run). Every key is optional; a missing key takes the built-in value (rematch_core::lint::DEFAULT_LINT_TOML, which the shipped file equals verbatim — a test enforces it). All [patterns] entries are Rust regex syntax; use TOML multi-line literal strings ('''…''') for patterns containing ' (inside '…', '' is not an escape).
[limits]always_on_bytes = 8192 # per branch, this entry + LintContext.branch_other_bytes: SKILL.md `description` values (omp lists every skill's description in each session's system prompt) and bodies (other frontmatter excluded), AGENT.md `description` values, AGENTS.fragment.md files (appended to the system prompt once approved)max_stems = 10 # predicate.stemsmax_exts = 8 # predicate.file_exts
[patterns] # rule ids in bracketssession_id = [...] # [identifiers] UUIDs (v4/v7), omp session file namesuser_names = [...] # [identifiers] home-dir paths that reveal a user; plus LintContext.user_names (literal, case-insensitive, word-bounded)task_paths = [...] # [identifiers] task-specific absolute paths (/Users, /home, /tmp, /var/folders)urls = [...] # [executable_risk]network = [...] # [executable_risk] curl, wget, nc, ssh, scppipe_shell = [...] # [executable_risk] curl|wget … | shdestructive_git = [...] # [executable_risk] push -f/--force, reset --hard, clean -fdstring_match_user = [...] # [string_match_user] branching on the user's literal wordsapproval = [...] # manifest.rationale matching any ⇒ executable_risk findings are Warn ("approval requested"), else Errororder_exception = [...] # manifest.rationale matching any ⇒ form_order findings are Warn, else Error
[form]order = ["trigger", "laws", "procedure", "pitfalls", "facts"][form.headings] # lowercase substrings of a ##-heading that classify it; unclassified headings are ignoredtrigger = ["trigger", "when to use", "scope"]laws = ["laws", "rules", "invariants", "constraints"]procedure = ["procedure", "workflow", "steps"]pitfalls = ["pitfalls", "anti-patterns", "gotchas"]facts = ["facts", "workspace facts", "reference"]
[hooks]probes = ["echo ok", "cargo test", "git status", "README.md", "src/lib.rs"] # a hook regex matching "" or every probe "matches everything"Rules (LintFinding.rule): identifiers, string_match_user, executable_risk (applied to every file in the entry dir except rematch.json and EXPERIENCE.md; markdown is linted with its rematch: frontmatter block removed; hook.json contributes only message + pass_condition); trigger (non-empty repo glob, stem/ext caps, non-empty trigger line present in the SKILL.md body or any Use when… line, non-empty description; for kind=agent the document is AGENT.md: frontmatter name equal to the entry name and not main/sub, non-empty description, no trigger-line or form-order check on the system prompt); scripts (every scripts/<path> referenced in markdown exists; no script executable unless listed in Entry.approved_scripts); always_on (the always_on_bytes sum above: SKILL.md description + body, AGENT.md description, AGENTS.fragment.md); form_order (trigger line, then classified headings, non-decreasing in form.order); facts_as_of (facts/*.md, and SKILL.md of kind=fact, need frontmatter as_of); hook (lint::hook_problems, per mode: every mode a rule, a non-empty pass condition and a tool (* = any), no parameter of another mode; block: field, message, regex compiles and does not match everything; stuck: message, repeats in 2..=20; truncate: max_age_turns 1..=100, keep_chars 200..=100000; retrieve: budget_bytes 500..=65536, min_overlap 1..=20; non-block modes take no regex/field; hook_module refuses the same problems); fragment (AGENTS.fragment.md non-empty, no #/## heading outside a code fence: it is appended under ## rematch: <name>); fork_path (Entry.fork_path repo-relative without .., library::validate_fork_path); transfer (LintContext.transfer and a domain lesson or fact). LintReport.ok iff no Error.
13. Ledger SQL (schema_version 2, rematch_core::ledger)
Section titled “13. Ledger SQL (schema_version 2, rematch_core::ledger)”WAL, synchronous=NORMAL, busy_timeout 5 s. Migrations are an ordered list; schema_version(version, created_at) records each applied one; a ledger newer than the binary is refused. Blobs: <ledger dir>/blobs/<sha256> (atomic write via rename).
| table | columns (JSON = serde JSON text) | keys / indexes | notes |
|---|---|---|---|
sessions |
id, cwd, repo, branch, started_at, ended_at, family, provider, model, turns, tokens_in, tokens_out, tool_calls, tool_errors, skill_reads JSON, files_touched JSON, exts JSON, stems JSON, redirects JSON, flagged, counted, source, json (full SessionRecord), created_at |
PK id; (branch, started_at); (counted, flagged) | reads deserialize json, then take flagged/counted from the columns and redirects from the redirects table. counted never goes back to 0 on re-upsert. Skill filter: json_each(skill_reads). |
redirects |
id, session_id, seq, prelabel, class, confidence, text_head, deliverable_head, t, created_at | UNIQUE(session_id, seq) | classifier labels (set_redirect_class) survive a re-import that carries none. |
entries |
id, json (full Entry), created_at, updated_at |
PK id | |
entry_status |
id, status, branch, name, predicate_hash, kind, family, updated_at | PK id; (status, branch); (name); (predicate_hash) | written in the same transaction as entries; predicate_hash = predicate::predicate_hash. |
counters |
entry_id, session_id, family, matched, recurrence, harm, tokens, turns, ts (session end), created_at | PK(entry_id, session_id); (family, ts) | one row per matched session ⇒ folding is idempotent. Aggregates and harm_times are computed (ledger::aggregate). |
proposals |
id (uuid v7), entry_id, session_id, blob (sha256 of proposal JSON), lint_ok, created_at | (entry_id); (session_id) | |
receipts |
id, entry_id, certificate_hash, activation, created_at | (entry_id) | approval certificates are blobs of {entry, what, files:[[path, sha256]], at} (what = scripts |
admissions |
predicate_hash, day (UTC YYYY-MM-DD), entry_id, created_at |
PK(predicate_hash, day, entry_id) | |
jobs |
id, kind, status, json, created_at, updated_at | (status) | json is the binary’s job payload. |
outcomes |
id, session_id, ts, machine, family, repo, branch, kind, domain, subject_id, subject_json, claim, signature, params_json, evidence_json, result_json, links_json, provenance_json, status, json, created_at | PK id (latest status per id); exact (signature); temporal (subject_id, ts); (domain) | subject_id = domain or domain#type:id,… (sorted entities), ledger::subject_id. latest/asof(ts) = newest by ts (≤ ts). |
outcome_facets |
outcome_id, domain, key, value | composite (domain, key, value) | prefix seek: domain = p OR domain LIKE 'p.%' (via substr). |
minhash |
outcome_id, band, hash | PK(outcome_id, band); (band, hash) | minhash_neighbors(bands, min_shared) is a suggestion, never a gate. |
journal (v2) |
seq (autoincrement), known (ledger ms at the write), machine, table_name, row_key, op (insert/update/snapshot), json |
PK seq; (table_name, row_key, seq) | ADR 001: one row per write to sessions, redirects, entries, counters, proposals, receipts, outcomes, appended in the write’s transaction; never trimmed. json = the record as stored: the hydrated SessionRecord (flags, redirects), Redirect (labels), Entry, CounterRow plus the entry’s content_sha256 and predicate_hash at fold time, ProposalRow, ReceiptRow, Outcome. row_key = id; <session_id>#<seq> for redirects; <entry_id>#<session_id> for counters. A write whose json equals the row’s last journaled json is not journaled again (a re-fold that changed nothing). Migration 2 backfills one snapshot row per existing row, known = its updated_at, else created_at, tables in the order above, rows by (known, key). Not journaled: admissions, jobs, minhash, outcome_facets, entry_status (derived or rebuilt). journal_since(seq), journal_max_seq(), apply_journal_row(row) (replays the record as the row’s current state, without a new journal row, and copies the journal row verbatim, ignored when its seq exists: idempotent; lake rebuild). |
meta (v2) |
key, value | PK key | machine = $REMATCH_MACHINE, else the host name, when the ledger got its journal; fixed afterwards (Ledger::machine_id). |
14. Binary module map and the omp process interface
Section titled “14. Binary module map and the omp process interface”crates/rematch/src/:
main.rs clap dispatch → one JSON envelope on stdout (envelope.rs); exit codes 0/1/2/130; `--version` → {"version"}app.rs AppCtx{paths, ledger, now, cwd, text}: ensure_dirs, improver files (embedded; `$REMATCH_IMPROVER` overrides) copied on first run, ledger open; `new_entry`envelope.rs Envelope{ok, operation, data|error}; `--text` renders `data.lines` (else pretty JSON); `Cancelled` → 130, `Usage` → 2config.rs omp config.yml (agent dir + <repo>/.omp/config.yml overlay) → modelRoles lookup; role fallbacks (§0) + `rematch-solver`→defaultproc.rs bounded process execution with process-group kill (shared by editor, classifier, batch rollouts, verifiers)omp.rs omp invocation builders + JSON event stream parsing/verification (forge omp_runner contract)editor.rs model calls (editor, classifier, reflector) through omp or `--editor <cmd>`; tolerant reply parser; per-reflection CallBudget (≤ 3)jobs.rs detached jobs: `spawn_detached(ctx, kind, argv, payload) -> job_id` (setsid, stdio→jobs/<id>.log, `{job}` token + $REMATCH_JOB_ID), `update`, `status` (kill(pid,0) liveness), `LockFile`library_cmd.rs library subcommands, off|onskills_cmd.rs skills sync: the shared skills dir for a cwd (§16 "Shared skills"), Claude Code plugin link and agent copies, Codex reach check, migration from claude-skills/ codex-skills/reflect.rs count + reflect --pending (§loop 1–8), editor/classifier calls, lint retry, stage, EXPERIENCE.md on decisionscreate.rs greenfield (sessions | task | description→draft), activateimprove.rs brownfield (register, audit, split, unit-level propose, stage beside)import_cmd.rs import --history / --forgedigest.rs session-start digest payload; capability-floor review list; transfer suggestions; ship candidates (`scope::ship::ready`)retrieve.rs retrieve: unit ranking for the `retrieve` context hookbuiltins.rs built-in context entries (ensure on open, `builtins ls|reinstall`)doctor.rs doctoruninstall.rs uninstalllake_cmd.rs `lake export|import|rebuild|doctor` over `rematch-lake` (feature `lake`; stubs reporting the missing feature without it), `export_after_reflect` (the bounded export at the end of `reflect --pending`); `lake rebuild` runs before AppCtx opens (§19.1)lake_migrate.rs `lake migrate` (L1 identity fields on rows that predate them; (re)applies the domain policy, §6 "Domain"; no feature needed)policy_cmd.rs `policy show | domain set|unset | caps set` (the one writer of policy.json, atomic replace) and the digest's domain proposals (§6 "Domain")lake_read.rs readers over the lake with the ledger fallback (§19.3): `Source`, `lake`/`current_lake`/`lag`, `lake_rows`/`ledger_rows`/`rows` (journal rows of a table), `as_of` (ADR 001's dedupe), `lake_events` (a session's events file by path)report.rs `report`: the kill criterion over a window (§19.3)tune.rs `tune` and the tune pass inside `count`: read signals → proposer → guards → provisional sibling (§6 "Predicate tuning")batch/ Phase 2: tasks.rs (task defs), substrate.rs (worktree + overlay materialization), rollout.rs, verifier.rs, run.rs (k=1/width scheduler), worker.rs (run-worker)scope/ Phase 3: ship.rs (team/fork PR), export.rs (work↔home export/import), stale.rs (drift), split.rs (per-family split), transfer.rs; anchor.rs (anchor replay before a replacement of a kept entry, §7)proc.rs (owned by Binary; used by everyone)
Section titled “proc.rs (owned by Binary; used by everyone)”pub struct Bounded { pub stdout: String, pub stderr: String, pub code: Option<i32>, pub killed: bool, pub elapsed_ms: u64 }/// Spawns `cmd` in its own process group (`process_group(0)`), stdin null, stdout/stderr piped (bounded buffers),/// waits ≤ `timeout`; on timeout or cancel (SIGINT/SIGTERM to the binary ⇒ `Cancelled`, exit 130) fires SIGTERM to the group,/// waits 2 s, SIGKILL to the group (also when a lingering grandchild keeps the pipes open after the leader exited).pub fn run_bounded(cmd: std::process::Command, timeout: std::time::Duration) -> anyhow::Result<Bounded>;pub fn run_bounded_input(cmd: Command, timeout: Duration, stdin: Option<&[u8]>) -> anyhow::Result<Bounded>; // scripted editorsomp.rs (owned by Binary)
Section titled “omp.rs (owned by Binary)”pub fn omp_bin() -> PathBuf; // $OMP_BIN → PATH `omp`pub struct NoToolsCall { pub model: ModelRef, pub system_prompt_file: PathBuf, pub prompt: String, pub timeout: Duration }pub fn no_tools_argv(call: &NoToolsCall, cwd: &Path, no_tools_config: &Path) -> Vec<String>; // §9 argv, exactlypub struct ToolsCall { pub model: ModelRef, pub cwd: PathBuf, pub prompt: String, pub tools: Option<String>, pub max_time: Duration, pub config_overlay: Option<PathBuf>, pub extra_args: Vec<String> }pub fn tools_argv(call: &ToolsCall) -> Vec<String>; // `--cwd --model --no-session --no-title --no-extensions --no-rules --no-lsp --approval-mode yolo --mode json --max-time -p -- prompt` (+ `--no-skills` unless overlay supplies customDirectories, + `--tools`)pub fn child_env() -> Vec<(String, String)>; // PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0pub fn parse_events(stdout: &str) -> anyhow::Result<Vec<serde_json::Value>>; // every non-empty line must be JSONpub struct Verified { pub text: String, pub events: Vec<serde_json::Value>, pub usage: Option<serde_json::Value> }pub fn verify_stream(events: Vec<Value>, expected: &ModelRef, no_tools: bool) -> anyhow::Result<Verified>; // §9 rulespub fn run_no_tools(call: &NoToolsCall, paths: &RematchPaths) -> anyhow::Result<Verified>; // tmp cwd, run_bounded, verifypub fn run_tools(call: &ToolsCall) -> anyhow::Result<Verified>;--editor <cmd> (tests): editor::call bypasses run_no_tools, pipes the prompt to the command’s stdin and reads stdout (see below).
Binary behavior notes (Phase 1 + 1.5)
Section titled “Binary behavior notes (Phase 1 + 1.5)”- Scripted model (
--editor <cmd>): run withsh -c, prompt on stdin, envREMATCH_ROLE=<role>andREMATCH_PROMPT=editor|classifier|reflector; stdout is the reply. Without it, the role’s model is resolved from config.yml and called throughrun_no_tools. The reflector uses therematch-editorrole withimprover/reflector.md. - Model calls:
countmakes ≤ 1 batched classifier call per pass (skipped when there are no unclassified non-mark redirects) and one reflector call per keep/retire (deterministic EXPERIENCE record when it fails). Each reflection’sCallBudget(3)is charged for the classifier when its session was in that batch, then editor + one lint retry. A failed editor call keeps the pending file (attemptscounter; dropped after 3). - Detached jobs: the child of
spawn_detachedwrites its final envelope intojobs/<id>.json(status: done|failed,result); a live status whose pid is gone reads aslost.run-workermanages its own record.REMATCH_JOB_IDis stripped from everyrun_boundedchild. - Ownership: an entry owns its directory iff it lives under REMATCH_ROOT or its SKILL.md carries a
rematch:block with its id (reflect::owns_dir). Registered skills (import --forge,improve) and lessons are foreign: decisions, disable and pin are recorded in the ledger only (reflect::record_status), never moving or rewriting their files; their EXPERIENCE.md goes toREMATCH_ROOT/brownfield/<id>/; approve/decline/rollback refuse them. import --forge: skills register in branchforgewithpath= the forge skill dir; eachlearnings/*.mdbecomes a provisionallessonatREMATCH_ROOT/lessons/forge/lesson-<slug>/(LESSON.mdcopy +rematch.jsonwithevidence_sha256= blob hash of the file,source_path).matchomits lessons.import --historykeeps an existingsource: tracesession row (richer) instead of overwriting it, and records the session file assource_path;countfolding a synthesized trace refreshes the import row’s signals and keeps itsource: import(counted once, never replaced by a trace row).improve: the audit counts every ledger session with the skill inskill_reads(traced and imported); < 5 ⇒{refused, traced_sessions, offer}. The excerpt and clusters come from each signalling session’s live trace, else the trace synthesized from its omp session file (import_cmd::records_for_session: recordedsource_path, else<agent dir>/sessions/<cwd slug>/*_<id>.jsonl, else a scan); the moment is the most recent session’s earliest undismissed redirect, else the first cluster of the session with the most error clusters;clusterslists redirect clusters anderror_clustergroups (failing tools, error heads). Siblings share the base predicate (no pending-canary check), getsibling_ofand stage atstaging/<branch>/<name>/where branch =--branch, else the reading sessions’ common repo, elseshared.create --from sessions: request stems (name + description) and--filesextensions vs. session stems/exts; a session matches with overlap ≥ min(2, |request stems|); ≥ 3 needed; up to 8 references.activate --taskruns one reference rollout and attaches its moment as evidence before draft → provisional.digestlines: one per entry whose status changed sinceREMATCH_ROOT/digest.last(imported registrations summarized in one line), plus Scope review/stale/transfer lines; approvals = one item per owned provisional/kept/pinned entry with a pending part (library::pending_parts: scripts, hook, agent, prompt; hook reason = trigger line or manifest rationale +Pass condition: …), plusexport:<id>items.digest.lastadvances to the later of the run’s start and the newest status time shown, so an entry the same process changed (built-ins installed on a first run) is not shown twice. Shown by omp at session start (notifications + approval dialogs) and by the Claude CodeSessionStarthook (§18.1 “Digest”).ship_ready(scope::ship::ready, read-only) lists kept/pinned non-built-in entries not yet shipped for the scope (§15); each id:scope also becomes one lineship <scope> <name>: <detail> → /rematch ship --scope <scope> --entry <id>the first time only (state inREMATCH_ROOT/digest.ship, newline-separated<id>:<scope>keys already announced, written withdigest.last).- Approvals (
library::{Approval, pending_parts, approve}): an entry has up to four parts that wait for one tap —scripts(files underscripts/not inapproved_scripts, rematch-owned entries only),hook(no compiled module),agent(a provisionalkind: agententry whoseAGENT.mdsha256, without itsrematch:frontmatter block, differs fromapproved_agent),prompt(AGENTS.fragment.mdpresent and its sha256 differs fromapproved_fragment). The digest has one item per entry,kind= the pending parts joined with+in that order,diff=+++ AGENTS.fragment.md/+++ AGENT.mdtext, the hook module and script files concatenated up toAPPROVAL_DIFF_BYTES(4,096).library approve <id>approves all pending parts in one call (one receipt each,Activation::Prompted) and bails only when nothing is pending;declineretires the entry. An edited fragment or agent body is pending again. Approving an agent links it and the extension reloads (changesLoaded); links of unapproved agents are removed at the next reconcile. Disable, decline or retire removes an approved fragment from the prompt. reflectcontent-triage failures are logged (stderr + envelope) and the pending file deleted; no ledger proposal row.
15. Scope (Phase 3, crates/rematch/src/scope/)
Section titled “15. Scope (Phase 3, crates/rematch/src/scope/)”CLI additions: stale [--dry-run] [--recanary <entry>], split [--review-days 7], transfer promote; ship adds --push, --pr (needs --push and gh), --remote origin; library export adds --entry <id>… --approve | --approve-all.
New Entry fields (serde default, omitted when empty): fork_path, fork_repo, fork_commit, families_excluded, links (transfers_from).
| Command / fn | Behavior |
|---|---|
ship --scope team |
Kept/provisional entries whose predicate content (count_hit, ignoring the one-repo glob) hit sessions in ≥ 2 distinct repos, or listed with --entry. Target = --repo or the originating session’s repo. Lint must pass. A temporary worktree from HEAD gets branch rematch/<name>-<last 8 id chars> with .omp/skills/<name>/ (no rematch.json/EXPERIENCE.md/AGENTS.fragment.md, mode 0644, rematch.scope: team) — an agent ships as the single file .omp/agents/<name>.md (its AGENT.md) — and, when the entry has AGENTS.fragment.md, <repo>/AGENTS.md with the section ## rematch: <name> (file created if missing; an existing section of that heading, up to the next #/## heading, is replaced: ship::upsert_agents_section), committed with an evidence body (counters, per family, matched sessions, repos, families, redirect class, predicted fix/regression, latest EXPERIENCE.md record). Receipt activation: pr. No network unless --push/--pr; never merges. |
ship --scope fork |
Entries with fork_path (set by library set-fork-path <id> <path> [--repo <fork>] — ledger + rematch.json, a changed target clears fork_commit — or proposed by the editor as entry.fork_path; a hand edit of rematch.json is adopted too). Target = --repo, else the entry’s fork_repo. Content = fork/<basename> in the package, else the SKILL.md (agents: AGENT.md) body. Body adds omp --version, file sha256, base commit, before numbers (the predicate over sessions that started before the entry existed, recurred/harmed rules, per family) and after numbers (entry counters). Records fork_repo + fork_commit (base HEAD). |
ship readiness (scope::ship::ready, entry_ship_ready) |
Read-only, no ledger writes: Kept/Pinned, non-built-in entries with no Activation::Pr receipt for the scope. team: recurrence_repos ≥ 2 (detail <n> repos, plus , AGENTS.md section when the entry has a fragment); fork: a fork_path (ledger or rematch.json; detail = the path). command = /rematch ship --scope <scope> --entry <id>. Feeds the digest’s ship_ready and library ls. |
library export |
policy.json `{environment: work |
library import-export |
Each <dir>/<id>/rematch.json: skip when already present (same id or transfers_from link), not invariant, or a near-duplicate (dedup::route_duplicate) of a live entry; branch = recorded branch when this library knows it, else shared; repo glob = that branch’s latest session repo, else **; lint transfer: true; staged provisional with origin Import{source: "export:<id>"}. |
stale |
Kept/provisional, unpinned entries go stale when (a) a facts/*.md source: file’s sha256 differs from its source_sha256 frontmatter (written by stale::snapshot_sources after staging) or the file is gone, (b) the last 3 counted sessions in the branch (all branches for shared) all ran a family ≠ family and ∉ verified_on, (c) git rev-parse HEAD of fork_repo ≠ fork_commit. --recanary stages a new version (new id, version + 1, predecessor, family = the one now in use, fork commit cleared, sources re-snapshotted) and retires the stale one. |
split |
counters::family_divergence ⇒ sibling <name>--<family slug> (provisional, same branch, family = harmed, sibling_of, version + 1, original files + units/family-note.md, linted); the original gets families_excluded += harmed (informational: entries load regardless of family). Idempotent. capability_review(window): families whose recurrence or harm rose on ≥ 2 entries in the last window vs the one before; review list only. |
transfer suggest / apply / promote |
Facets: branches/<b>/domain.yml facets: else distinct outcome facet keys of the branch (docs/domain.yml.md). Suggest kept invariant lessons for branches overlapping on ≥ 2 keys and not yet tried (same name, link, near-duplicate). Apply: provisional copy (new id, links: transfers_from, origin Import{source: "transfer:<id>"}, repo glob of the target branch, facts/ dropped, approvals reset), lint transfer: true, rate limits except NeedsMerge. Promote: an entry kept in one branch plus kept (same name, link or near-duplicate) or a clean canary (matched ≥ 4, no recurrence/harm) in another ⇒ copy to shared (repo glob **) and keep it at once; library moves the branch version to predecessors (dormant). When the copy takes a kept entry’s slot, the anchor replay (§7) gates the keep: pass ⇒ kept plus an anchor receipt (canary: null); fail ⇒ the copy is retired with anchor replay failed: …, the kept entry stays, and that entry is not promoted again (a retired copy linking it with that note settles it); a replay error ⇒ the copy is retired with anchor replay deferred: … and the next pass retries; no verifier ⇒ kept. The promotion row carries `anchor: {kept, replay |
File ownership (all scope status changes and rematch.json writes): an entry’s files are rematch’s only when it is not reflect::is_foreign and its dir holds a rematch.json with its own id (scope::owns_files). User-owned entries (brownfield registrations in place, forge imports) get status changes in the ledger only (reflect::record_status); their dirs are never moved or written, and they are never shared-promotion sources. Entries with family unknown/unknown (imports) never go stale on the model-family rule.
16. Harness surfaces and how each is reached
Section titled “16. Harness surfaces and how each is reached”Harness² edits eight surfaces (research D3). Each maps to one omp component; activation follows SPEC §“Scope and who merges” (skill, rule and fact text automatic as provisional; subagents, system-prompt text, scripts and hooks behind a one-tap approval; team and fork only as a branch/PR the binary never merges).
| Surface | omp component | Mechanism in rematch | Activation | Status |
|---|---|---|---|---|
| System prompt | <repo>/AGENTS.md (context file); per-model prompt text in the omp fork |
AGENTS.fragment.md in an entry → personal: block ## rematch: <name> + the fragment appended to the system prompt of main sessions where the entry loads (omp and Pi before_agent_start, OpenCode experimental.chat.system.transform, Claude Code SessionStart additionalContext; Codex: not supported, skills only); team: section ## rematch: <name> of <repo>/AGENTS.md (ship --scope team); fork prompt files via fork_path (ship --scope fork) |
personal: one tap (digest kind prompt; bound to the fragment’s sha256, so an edit asks again); team / fork: PR only |
built; the user’s own AGENTS.md is never written; the fragment counts toward the 8 KB always-on budget and match.prompt_fragments is capped at it; disable, decline or retire removes it |
| Guardrails / rules | laws in a triggered skill’s router SKILL.md |
## Laws of a skill entry (kind: rule entries are skills); lint form order |
automatic as provisional, counters keep or retire |
built; standalone always-on rule files deferred (they would cost every session) |
| Memory / facts | facts/*.md inside a skill (each with as_of), EXPERIENCE.md beside entries |
fact entries and units; stale on source drift; the reflector writes EXPERIENCE.md after keep/retire and the editor reads matching records |
automatic; domain facts never transfer | built; omp’s own memory backend is not written |
| Tool guidance | the fork’s tool descriptions / rules text | fork_path (library set-fork-path or entry.fork_path from the editor) + fork/<basename> → ship --scope fork |
fork PR only | built |
| Subagents | ~/.omp/agent/agents/<name>.md (user), <extension-root>/agents/*.md |
kind: agent entry (AGENT.md + rematch.json); provisional, once approved: extension/agents → REMATCH_ROOT/agents/<name>.md → staging/<branch>/<name>/AGENT.md; kept: copied to ~/.omp/agent/agents/<name>.md (predecessor saved; with a profile copied to profile/agents/<name>.md and linked, §20); team: .omp/agents/<name>.md |
one tap for a provisional agent (digest kind agent; bound to the sha256 of AGENT.md without its rematch: block, so a body edit asks again): not linked or copied anywhere before; then visible in every session, counted where the glob hits |
built |
| Skills | ~/.omp/agent/skills/<name>/SKILL.md |
linked into the shared dir REMATCH_ROOT/skills/ (below), which the factory adds to skills.customDirectories; kept: copied natively to ~/.omp/agent/skills/<name>/ (with a profile: to profile/skills/<name>/, linked from there, §20) and linked into the shared dir too (one skill by realpath, §0); team: .omp/skills/<name>/ |
automatic as provisional |
built |
| Scripts | scripts/<n> inside a skill |
staged mode 0644; library approve (or the digest’s one tap) makes them 0755 |
one tap | built |
| Hooks / plugins | ~/.omp/agent/extensions/rematch-hooks/<id>.ts (data-only modules; with a profile links to profile/hooks/<id>.ts, §20), imported by the extension and run on tool_call (block, stuck) or context (truncate, retrieve) |
kind: hook entry (hook.json with a mode), compiled on approval; the three built-in entries (§10) are hook entries of the context modes |
one tap | built: block verifiers, stuck detection, age-based tool-output truncation, harness-driven unit retrieval; other plugin shapes (error hints) are not generated |
Shared skills (REMATCH_ROOT/skills/)
Section titled “Shared skills (REMATCH_ROOT/skills/)”One directory delivers skill entries to every harness: REMATCH_ROOT/skills/<name> → the entry dir (staging/<branch>/<name>/ or the kept ~/.omp/agent/skills/<name>/). library::sync_shared_skills writes it; rematch skills sync --cwd <dir> [--harness omp|claude-code|codex] runs it for a cwd (its repo, else itself) and returns {added, pruned, total, dirs, links, untouched, off, …}.
| Harness | Reads the shared dir through | Synced for a cwd by |
|---|---|---|
| omp | skills.customDirectories += [REMATCH_ROOT/skills], set in the factory (per process, §10) |
the factory (skills sync --harness omp) |
| Claude Code | the plugin’s skills/ → REMATCH_ROOT/skills (created or re-pointed at SessionStart) |
the SessionStart hook (reloadSkills: true when a link changed, §18.1) |
| Codex | ~/.agents/skills/rematch → REMATCH_ROOT/skills (one link the user makes; Codex scans user roots recursively, §18.2.1) |
skills sync --harness codex --cwd <repo> before Codex starts (§18.2.3) |
- What is linked. Every kept or pinned entry (status
kept/pinnedor the human pin), and every provisional entry whose predicate matched a cwd that was synced — the load-time rule oflist_for_match(repo glob against the repo, else the cwd; hooks only once approved). Only entries rematch owns (their dir holds their ownrematch.json; a user skill registered in place is never linked) with aSKILL.md; agents go throughagents/(Subagents row), lessons are library records. A link targets the entry’s recorded path, so a skill kept under one omp profile’s agent dir loads in every profile (REMATCH_ROOTis shared, §1). With a profile (§20) everyprofile/skills/<name>/holding aSKILL.mdis a kept source too — kept here, kept on another machine, or adopted — and fills any name no entry holds. - Loading is the union of the branches used on this machine. A sync adds the cwd’s matches and never removes a provisional link because this cwd does not match it: another session, in another repo or another harness, may be loading it.
- Pruning. A link goes when its entry stops being provisional/kept/pinned (retired, dormant, stale, back to draft, deleted) or moved (a keep re-points it to the kept dir), and every link goes while rematch is off. Every status change runs the sync without a cwd —
set_status(keep, retire, dormant, stale),stage,activate,rollback, pin/unpin,off/on— so a retirement leaves the dir at once and a keep re-points at once; only a cwd sync adds provisional links. - Counting is unchanged. Each session’s
loaded_entriesstill comes fromlist_for_matchfor its own cwd (Codex: the import’s count-time predicate, §18.2.3): an entry loaded through the shared dir whose predicate misses the session is not counted. The union therefore costs one description line per extra skill in the system prompt and never a counter. - One name, one link. A provisional entry matched by the current sync takes its name; a provisional candidate already linked shadows the kept entry of the same name until it is kept or retired; kept/pinned entries fill every other name. Non-symlinks in the dir are never touched (
untouched); hidden names are skipped. - Atomic and idempotent. Each link is written as a hidden temporary symlink renamed over the name, so a reader sees the old target or the new one, never a missing name; a sync with nothing to change writes nothing (
addedandprunedempty). - Race rule. Syncs run without a lock: each only adds links to live entries and removes links to dead ones, so concurrent syncs converge; two matching cwds wanting different entries under one name leave the last writer’s link; a prune re-reads the link and removes it only while it still points where the sync saw a dead entry. A session sees the dir as of its own skill scan (omp at process start, Claude Code at
SessionStartor its re-scan, Codex at session start); a link pruned under a running session dangles there — retirement wins over a running session, and a link added later reaches the session’s successors. - Migration. The first sync removes the per-harness link dirs of older versions,
REMATCH_ROOT/claude-skills/andREMATCH_ROOT/codex-skills/(their links; a dir holding anything else stays).--harness codexalso re-points~/.agents/skills/rematchand<repo>/.agents/skills/rematchwhen they still point atcodex-skills, and removes the kept copies older versions wrote under~/.agents/skills/<name>/(marker.rematch-codex.json);uninstallremoves the shared dir (links only, never their targets) and any such copies.
17. Batch mode (Phase 2, crates/rematch/src/batch/)
Section titled “17. Batch mode (Phase 2, crates/rematch/src/batch/)”New Entry field (serde default, omitted when false): pending_next_run — a batch winner staged by a run of its Origin::Task, decided by that task’s next run.
Task (tasks/<id>.json): {id, repo (absolute), prompt, verifier?, max_time_secs (default 600), tools?, seeds (default [1]), created_at}. omp has no sampling seed: the seed names the workspace and is exported to the rollout as REMATCH_SEED.
Verifier specs (run --verifier, else the task’s): tests (Cargo.toml → cargo test; pyproject/pytest.ini/setup.* → python3 -m pytest -q; package.json + bun lock → bun test), parity:<produced>=<expected file | sha256>, receipt:<path> (JSON verdict: pass|fail, optional score), [script:]<cmd> (sh -c in the worktree, exit 0 = pass, last stdout JSON line with score supplies the score). Verifiers run with env REMATCH_TASK, REMATCH_WORKTREE, REMATCH_REPO, capped at max(task cap, 60 s).
Rollout: git worktree add --detach <variant>/wt HEAD (plain cp -R for a non-git repo); the harness (kept/pinned entries + provisional entries list_for_match loads for the repo, candidates shadowing same-named entries) is written to <variant>/skills/<name>/; --config <variant>/overlay.yml:
skills: {enabled: true, enableCodexUser: false, enableClaudeUser: false, enableClaudeProject: false, enablePiUser: false, enablePiProject: false, enableAgentsUser: false, enableAgentsProject: false, customDirectories: ["<variant>/skills"]}memory: {backend: "off"}memories: {enabled: false}(keys from omp config list, omp 18.12.6; a probe showed only the custom-dir skill to the model). argv = omp::tools_argv with the rematch-solver role (→ default), env child_env() + REMATCH_SEED + REMATCH=0, bound = task cap + 60 s. stdout → events.jsonl (+ ledger blob); the stream must pass verify_stream (else verdict Fail “rollout invalid” without the verifier; a time-capped rollout is still judged). events_to_trace maps session → session_start, message_end user/assistant/toolResult → input/turn_end/tool_result (input recovered from the toolCall block), agent_end; the worktree path is rewritten to the repo.
Worker (run-worker): reference rollout (pending entries of the task loaded) → next-run rule: entries with pending_next_run and Origin::Task{task} go kept (+ verified_on, receipt) on a reference pass, retired on a fail; a pass for an entry that replaces a kept entry first runs its anchor replay (§7; the run’s omp binary, this task’s lock reused when the anchor task is this task): replay fail ⇒ retired with the anchor replay failed: … note and the kept entry stays, replay pass ⇒ kept plus a second receipt with the anchor verdict, replay error ⇒ the flag stays for the next run; the row in next_run carries anchor: {kept, replay, certificate?} → if the reference passes a score-less verifier the cycle stops (nothing can rank above it) → k invariant + k domain editor calls (editor.md; input = §9 shape with redirect {class: correction, text: verifier detail}, task.prompt, verifier.verdict, prior_candidates for i > 1; excerpt = around the longest consecutive tool-error stretch, else the trajectory tail), each linted and probed once → every (invariant, domain) pair composed: the best pair by one editor call with scope: compose (+ candidates, behavioral_diff) while the model-call budget lasts, the rest (and composer KEEP_BASE/failure/lint failure) by deterministic merge (union of files; domain laws that are simhash near-duplicates of an invariant law dropped; same-named entries merged) → each composed harness runs once; with ≥ 2 composed variants the best runs once more and must pass both → selection: (pass, score) desc, then tool errors, turns, lesson count, order; a winner must pass and beat the reference, else KEEP_BASE → winner lessons: a near-duplicate of a branch entry appends evidence to it; else one rate::can_admit per winner, staged provisional (origin Task, branch/predicate of the task repo, lesson_tag by scope, pending_next_run: true, receipt with the verdict). Budget at width k: rollouts = (k+1)² + [k>1] (4 at k=1, 10 at k=2), model calls = 2k + 1; anchor replays are outside it (one per replacing pending entry) and are not in the receipt’s rollouts. Progress in jobs/<id>.json (phase, rollouts_done, rollouts_budget, model_calls, verdicts); receipt runs/<task>/receipt.json (+ blob) with every rollout summary, proposal, lint report and the decision; workspaces and worktrees are removed after the receipt (also on failure). One run per task at a time (runs/<task>/.lock).
18. Adapters
Section titled “18. Adapters”One binary, one ledger, one lint and one set of counters and entries serve every harness. Adapters normalize host events into the trace schema (§3); cheap runtime checks stay in the adapter when the host requires an in-process handler, while admissions, reflection and counters stay in Rust. Session ids are namespaced: omp uses bare ids, Claude Code cc:<id>, Codex codex:<id>, Pi pi:<id>, and OpenCode oc:<id>. Pi and OpenCode may add :r<n> for a resumed segment so finished, counted evidence is never overwritten. import_cmd::Transcripts routes history, reflect --history, improve and lake export through the corresponding parser.
18.1 Claude Code (adapters/claude-code/)
Section titled “18.1 Claude Code (adapters/claude-code/)”Verified facts (Claude Code 2.1.285: claude --help, claude plugin --help, claude plugin validate, claude --plugin-dir <dir> plugin details <name>; the hooks, skills and plugin references at code.claude.com/docs; the CHANGELOG versions in brackets):
- A plugin is a directory with
.claude-plugin/plugin.json;hooks/hooks.json,commands/*.mdandskills/<name>/SKILL.mdare its default component locations. One session:claude --plugin-dir <dir>. Every session: a plugin directory under~/.claude/skills/<name>/loads as<name>@skills-dir(claude plugin inithelp text).~/.claude/plugins/is the marketplace cache (installed_plugins.json,cache/), and settings know onlyenabledPluginskeyedname@marketplace; a marketplace install copies the plugin and drops symlinks that leave it, so it is not a route for this adapter. - Command hooks in exec form:
command+args[2.1.139],${CLAUDE_PLUGIN_ROOT}substituted and exported. Payload on stdin; common fieldssession_id,transcript_path,cwd(followscd/worktrees),hook_event_name,permission_mode, plusagent_id/agent_typewhen a subagent’s tool fires the hook. Event fields:SessionStartsource(startup|resume|clear|compact|fork),model(optional);UserPromptSubmitprompt(as typed; also fired for background-task reports and cross-session messages);PreToolUsetool_name,tool_input,tool_use_id;PostToolUse+tool_response(successful calls only);PostToolUseFailure+error,is_interrupt[2.1.119];Stoplast_assistant_message[2.1.47] (not fired on a user interrupt);StopFailure(API error) [2.1.78];SessionEndreason. - Exit 2 on
PreToolUseblocks the call and Claude reads stderr as the reason; any other non-zero exit is a non-blocking error; exit 0 with one JSON object on stdout is structured output.UserPromptSubmithookSpecificOutput.additionalContextadds context next to the prompt; above 10,000 chars Claude Code moves it to a file.SessionStarthookSpecificOutput.reloadSkills: truere-scans skill directories after the SessionStart hooks [2.1.152] (discovery normally runs before they finish).SessionEndhooks get 1.5 s; a plugin’stimeoutdoes not raise that budget (CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MSdoes). - A plugin’s
skills/that is a symlink to a directory elsewhere, and symlinked skill folders inside it, are followed by a session:claude plugin validatesays so (“A session loading this plugin does follow it”) andclaude --plugin-dir … plugin detailslisted the skills behind both kinds of link. Plugin skills are namespaced/<plugin>:<skill>(theSkilltool’sskillinput isrematch:<name>). - A plugin’s
agents/*.mdload from a real file: probed withclaude --plugin-dir <probe> plugin details(2.1.285,CLAUDE_CONFIG_DIRpointed at a temp dir), an agent file inside a symlinkedagents/directory is listed, a symlinked agent file (in a real or a symlinkedagents/) is not.REMATCH_ROOT/agents/holds file symlinks (<name>.md → staging/…/AGENT.md), so the plugin cannot link it; it gets copies instead (“Agent loading” below), whichplugin detailslists (Agents (1) log-triage, live smoke). - Commands substitute
${CLAUDE_PLUGIN_ROOT},${CLAUDE_SESSION_ID}[2.1.9] and$ARGUMENTS, and run!`cmd`lines before the prompt reaches the model whenallowed-toolspre-approves them;disable-model-invocation: truekeeps a command from the model’sSkilltool. SessionStartoutput:hookSpecificOutput.additionalContextreaches Claude before the first prompt; the universalsystemMessageis shown to the user (in-p --output-format stream-jsonit arrives only inside thehook_responseevent, live smoke). No field opens a dialog or asks a question.- A prompt typed while Claude works is queued and delivered into the running run at the next tool boundary (transcripts:
promptSource: "queued"after atool_result);UserPromptSubmitfires at delivery, before that run’sStop(live smoke:-p --input-format stream-json, a second message sent 3 s into asleep 6call). An Esc abort fires no hook; Claude Code writes a user entry[Request interrupted by user…]to the transcript. - In
-pmode Claude Code kills async hooks still running at teardown; work that must outlive the session has to be detached from the hook. A--plugin-dirplugin takes the name of askills-dirplugin of the same name for that session (rematch@skills-dir: Not loaded — the name "rematch" is already taken), so a smoke test can load a copy of the adapter. - Transcripts:
~/.claude/projects/<slug>/<uuid>.jsonl, subagents under<uuid>/subagents/agent-<id>.jsonl($CLAUDE_CONFIG_DIRrelocates~/.claude). Format facts relied on: module doc ofrematch_core::import_claude.
Plugin. hooks/hooks.json wires UserPromptSubmit, PostToolUse, PostToolUseFailure, Stop, StopFailure, SessionEnd to sh scripts/hook.sh trace-event, SessionStart to sh scripts/hook.sh session-start (trace-event, then the reflection below) and PreToolUse to sh scripts/hook.sh hook-check. hook.sh pipes the payload unchanged to rematch <op> --harness claude-code --hook-output (binary: $REMATCH_BIN → <repo>/target/{release,debug}/rematch → PATH, as the omp extension; scripts/rematch.sh also exports CLAUDE_PLUGIN_ROOT = the plugin when unset, so the commands find it) and never fails the hook: it exits 2 only for an intentional block (binary exit 2 with nothing on stdout), prints stdout only on exit 0, and exits 0 otherwise. Under --hook-output the binary prints the hook reply (data.hook: stdout JSON, stderr, exit) instead of the envelope, and every error, including a usage error, exits 0. commands/rematch-*.md run, through !`…` lines: status (--text status + --text skills sync --harness claude-code), why (library why [$ARGUMENTS]), mark (mark --session cc:${CLAUDE_SESSION_ID} --class $ARGUMENTS), library (library ls), approve / decline (library approve|decline $ARGUMENTS), off (off), on (on + skills sync --harness claude-code); mark, approve, decline, off and on set disable-model-invocation. They hold no prompt text beyond “show the output”. create, improve, run, disable, rollback, ship and the rest stay shell commands.
Reflection. hook.sh session-start starts rematch reflect --pending after the session_start record is written, as the omp extension does at session start: set -m; nohup … </dev/null >/dev/null 2>&1 & — its own process group, no stdio, so the hook returns without waiting and the run outlives a -p session; the binary’s pending/.lock keeps it to one run. It starts only when omp is found ($OMP_BIN, else omp on PATH): the editor and classifier are omp -p calls (§9), and a failed call uses up a flagged session’s attempts. Starting after the record matters for resume: the trace ends with a session_start again, so count (finished: a session_shutdown after the last session_start) waits for the resumed run and counts the session once, whole. The model calls run in that detached process, never inside the Claude Code session. Live smoke: a session flagged by a negation in its resumed half was counted with that redirect and reflected at the next start.
Digest. At SessionStart (not compact) the binary runs rematch digest (which advances digest.last for every harness, as omp’s session-start digest does) and, when it has lines or approvals, replies systemMessage (the lines, then one line per approval: <kind> <name>: /rematch:rematch-approve <id> · /rematch:rematch-decline <id>, and rematch library approve|decline <id> for a shell; ≤ 4,000 chars) and additionalContext (the same text, an instruction never to approve or decline on the user’s behalf, and each approval’s reason and diff so Claude can show them; ≤ 9,000 chars). There is no one-tap approval: Claude Code hooks cannot ask.
Personal system prompt. At SessionStart (compact included, where the digest is omitted) the binary adds the approved AGENTS.fragment.md blocks of the entries that load for the payload cwd (match’s prompt_fragments; each rendered ## rematch: <name>, a blank line, the trimmed text; blocks joined by a blank line; ≤ the lint always_on_bytes) to additionalContext, under a header line [rematch] Personal instructions (approved):; the digest’s share of the context shrinks to leave room (10,000 chars in all). Nothing is written to CLAUDE.md or any AGENTS.md.
Events → trace (crates/rematch/src/trace_event.rs; one process per hook, so the state the omp extension keeps in memory is read back from the trace, which is flocked while a record is appended):
| Hook | Records | Notes |
|---|---|---|
SessionStart |
session_start (mode: "claude-code:<source>", omp_version: "claude-code", model from the payload, staging_dirs = [REMATCH_ROOT/skills]) |
matches the payload cwd (library::list_for_match), syncs the shared skills dir for it (skills_cmd::sync_cwd, §16 “Shared skills”), creates or re-points <CLAUDE_PLUGIN_ROOT>/skills → REMATCH_ROOT/skills, syncs the agent copies (skills_cmd::sync_plugin_agents); replies reloadSkills: true when a link changed, the approved prompt fragments (“Personal system prompt” below) and the digest. source: compact refreshes the links without a second session_start or digest; resume appends one to the same trace (a session counted before it was resumed is not counted again). loaded_entries = predicate hits minus lessons, truncate hooks and retrieve hooks (credited through retrieval), and minus agents whose copy was not in agents/ before this start; hooks_loaded = the approved block/stuck/retrieve modules |
UserPromptSubmit |
[agent_end] input, [retrieval] |
injected frames (is_injected_frame) are skipped. Takeover follows §3 (input while the agent loop runs): a run with no agent_end since its input is closed first (agent_end, not yielded); the prompt is idle when the transcript tail holds an [Request interrupted by user…] entry later than that input (Esc ended the loop: import_claude::aborted_after), else interrupt (delivered into the running loop: queued while Claude worked; also when the transcript cannot be read). Prelabel against the trace’s previous input, after_deliverable = the last turn_end.text_head; /rematch-mark <class> (also /rematch:rematch-mark) is spelled /rematch mark <class> (import_claude::canonical_prompt); other commands except /skill: are dropped as in omp. An approved retrieve hook ranks the loaded packages’ units for the prompt plus the paths of the last 10 tool results (retrieve::rank, the module’s budget_bytes/min_overlap) and, when the unit set differs from the last retrieval record, writes one and replies additionalContext with the units |
PostToolUse / PostToolUseFailure |
tool_result |
import_claude::tool_result_record: tool name lowercased (Read → read, Bash → bash, so omp-authored hook rules and file derivations apply), is_error = the failure event, content_head from tool_response (stdout/stderr, file.content, content, else the JSON) or error, skill_read from the Skill tool (plugin:name → name) or a Read of …/skills/<name>/SKILL.md, path from file_path/path/notebook_path |
Stop / StopFailure |
[model_change] turn_end…, agent_end |
one turn_end per API response in the transcript tail (last 8 MB) later than the last input/turn_end/agent_end (model, usage, stop_reason, text); if the transcript lags, one turn_end with last_assistant_message; yielded = Stop |
SessionEnd |
[agent_end] session_shutdown |
trace::flag_reasons (the extension’s rule); flagged ⇒ pending/cc:<uuid>.json (source: "claude-code") |
PreToolUse |
[tool_blocked] |
hook-check: the approved block/stuck modules matched for cwd, read back from hooks_dir/<id>.ts (the approved data, parse_hook_module). block: the Rust regex on tool_input[field] (path and file_path stand in for each other); stuck: identical calls (tool + args_hash) right before this one, read from the trace (tool_results and stuck rejections count, block rejections are skipped) ≥ repeats. A rejection writes tool_blocked (with args_hash) and exits 2 with the hook’s message on stderr. Subagents (agent_id): block applies, stuck does not (no trace) |
| subagent events, other events | — | the adapter traces main sessions only, as the extension |
Skill loading. The plugin’s skills/ is a symlink to the shared dir REMATCH_ROOT/skills/ (§16 “Shared skills”), created at the first SessionStart (the repo cannot ship an absolute link, so adapters/claude-code/.gitignore lists it) and re-pointed if an older version left it at REMATCH_ROOT/claude-skills, which that first sync deletes. SessionStart and rematch skills sync --harness claude-code --cwd <dir> (which also fixes the plugin link when $CLAUDE_PLUGIN_ROOT is set) run the sync; kept entries are linked there from ~/.omp/agent/skills/<name>/, so nothing is copied into ~/.claude/skills/: nothing outside REMATCH_ROOT and the plugin directory is written, and rematch uninstall (which removes REMATCH_ROOT) leaves only the dangling skills link in the plugin.
Agent loading. Claude Code lists a plugin’s agents only from real files, so skills_cmd::sync_plugin_agents (at SessionStart and in skills sync --harness claude-code, plugin = $CLAUDE_PLUGIN_ROOT, else <CLAUDE_CONFIG_DIR or ~/.claude>/skills/rematch) copies every approved provisional agent (omp offers each approved one in every session) and every kept or pinned one into <plugin>/agents/<name>.md (git-ignored; a provisional agent wins over a kept one of its name). The copy keeps name, description and the body, maps an omp tools list to Claude Code names (read → Read, bash → Bash, find/glob → Glob, fetch → WebFetch, task → Agent, …; unknown names dropped, none left = the field is dropped), keeps model only as a Claude Code alias or claude-… id (an omp role such as @smol is dropped: the session’s model) and drops other keys. agents/.rematch lists each copy ({"<name>.md": {"id", "sha"}}): a copy is rewritten or pruned only while its bytes are what the sync wrote, a file of the same name that is not a copy is left alone (and the entry not copied), and an empty agents/ is removed. A retired agent’s copy goes at the next sync, not at the status change, and so does the copy of a provisional agent that is not approved; uninstall removes the listed copies and the marker. Whether a copy written by a start’s own hook is offered in that session is not established (agents are read as the session starts), so loaded_entries credits only agents whose copy was listed before that start’s sync (live smoke: the first session wrote the copy and did not count it; its resumed half did).
History. rematch_core::import_claude (scan_sessions_dir, parse_session, trace_records_of, session_record, find_session_file): typed prompts (origin.kind absent or human, not isMeta/compaction summaries/injected frames/local command output; [Request interrupted by user…] marks an aborted run, so the next prompt is interrupt; a queued prompt is interrupt; <command-name>/x</command-name>…<command-args>a</command-args> → /x a) → input; tool_use + tool_result → tool_result; entries sharing message.id → one turn_end (thinking blocks dropped, <synthetic> model ignored); agent_end before each prompt and at the end; session_shutdown by flag_reasons. Subagent transcripts have no inputs (their user text is the parent’s) and are never reflected. import --history --harness claude-code|all folds each transcript into a source: "import" row; outcomes are not extracted (they come from omp tool receipts). The importer keeps the import rule of §3 (a prompt after an aborted run is interrupt, as for imported omp sessions); the live adapter follows the live rule (an abort ends the loop, so only a prompt delivered into a running loop is interrupt).
What Claude Code cannot do (compared with the omp extension):
truncatehooks: no hook rewrites earlier tool results or the outgoing request.PostToolUseupdatedToolOutput[2.1.121] replaces a result only as it arrives (age 0), whiletruncateshortens results once they aremax_age_turnsuser turns old, sotruncateentries are not run and not counted.retrieveruns once per prompt, not before every model request, and the injected units stay in the conversation (omp re-sends one non-persisted message per request); a unit set equal to the last one is not injected again.- Skill loading is not per process: the shared dir serves every Claude Code session (and omp and Codex), holding the union of what every synced cwd matched, and a concurrent session sees links added after its scan only after its next re-scan. Its trace still lists, and counts, only what was matched for its own cwd.
- No per-turn event:
turn_endrecords are recovered atStopfrom the transcript tail, which can lag; then oneturn_endwithout model/usage carrieslast_assistant_message. Model fallbacks inside a run are seen only as turn-to-turn model changes. - Esc fires no hook: the abort is read from the transcript at the next prompt (
[Request interrupted by user…]), and that prompt is not a takeover; a session that ends right after Esc closes the run atSessionEnd. A prompt queued while Claude works is a takeover only becauseUserPromptSubmitfires when it is delivered into the run (verified facts above); if a release delivered it afterStop, it would be idle. - The
stuckwindow sees completed calls only (parallel identical calls in flight are not counted); subagents getblockrules but nostuckdetection. - Hooks cannot open dialogs: the digest is a
systemMessage/additionalContextat session start and approvals are commands the user runs (/rematch:rematch-approve <id>, or the shell); there is no one-tap approval, and norematchtool for the model (Claude can run the CLI through Bash; the approve/decline/off/on/mark commands are not model-invocable). Reflection runs detached from theSessionStarthook and needs omp for its model calls; without omp it isrematch reflect --pendingfrom a shell (or the next omp session start) after configuring the roles. - Agent copies are refreshed at
SessionStartandskills sync --harness claude-codeonly, so a status change reaches Claude Code at the next start; omp-only frontmatter (modelroles, unknown tools, other keys) is dropped from the copy. - A session killed before
SessionEnd(or past its 1.5 s budget) keeps an unfinished trace thatcountskips — the same failure mode as an omp crash. - Imported Claude Code sessions carry no
loaded_entries(transcripts do not record what the plugin linked). - Batch mode (
run, anchor replay) drives omp rollouts only (batch/worker.rs,Substrate::rollout_cmdare omp-typed, the plan for another harness is §18.2.4).
18.2 Codex (adapters/codex/, rematch_core::import_codex)
Section titled “18.2 Codex (adapters/codex/, rematch_core::import_codex)”Codex gets no runtime code from rematch: evidence comes from its session rollouts after the fact, entries reach it as skill directories. No hook, no live trace, no model call inside a Codex session. Personal system-prompt fragments are not delivered to Codex (skills only).
18.2.1 Verified Codex facts
Section titled “18.2.1 Verified Codex facts”Rollouts: 86 local files under ~/.codex/sessions/ written by Codex Desktop cli_version 0.163.0-alpha.10.2 (read-only inspection, 2026-10-26); skill discovery: developers.openai.com/codex/skills and openai/codex main (codex-rs/ext/skills/src/loader/{discovery,host,mod}.rs, codex-rs/skills/src/{parser,mentions}.rs); exec --json: developers.openai.com/codex/noninteractive and sdk/typescript/src/items.ts.
| Fact | Consequence |
|---|---|
Rollouts live at $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<local ts>-<uuid>.jsonl (CODEX_HOME defaults to ~/.codex); each line is {timestamp, ordinal, type, payload} |
scan_sessions_dir walks for rollout-*.jsonl; the id is session_meta.payload.id (else the file name’s last 36 chars); ledger id codex:<id> |
Line types seen (count over 86 files): event_msg/item_completed 8815, response_item/{reasoning 3608, custom_tool_call 2188, custom_tool_call_output 2187, message 1662, function_call 209, function_call_output 209, compaction 15, additional_tools 20}, event_msg/token_count 2826, token_usage_record 2762, event_msg/thread_settings_applied 426, turn_context 378, event_msg/{task_started 355, task_complete 345, turn_aborted 8}, world_state 131, session_meta 86, compacted 24 |
unknown types are ignored |
session_meta.payload: id, timestamp, cwd, originator, cli_version, source ("exec" 67, "vscode" 2, {"subagent":{"other":"guardian"}} 17), thread_source, model_provider, parent_thread_id, base_instructions |
is_subagent = source.subagent or a parent_thread_id: reviewer threads, whose “user” turns are transcripts written by the parent, are imported but never reflected |
Model per turn: turn_context.model (+ effort); thread_settings_applied.thread_settings.{model, model_provider_id} |
turn_end.model = {provider: model_provider, id}; a change between turns → model_change |
Two representations of the same conversation. Thread items (event_msg/item_completed.item): UserMessage{content:[{type:text,text}|{type:local_image,path}]}, AgentMessage{content:[{type:Text,text}], phase: commentary|final_answer}, CommandExecution{command:["/bin/zsh","-lc",script], cwd:"file://…", parsed_cmd:[{type: read|search|list_files|unknown, cmd, path}], status: completed|failed, aggregated_output, exit_code}, FileChange{changes:{<path>:{type: add|update|delete, …}}, status}, McpToolCall{server, tool, arguments, status, result:{content, isError}}, ImageView{path}, Extension{kind:"web.search", query}, Reasoning, ContextCompaction. Model-facing items (response_item): message{role: developer|user|assistant}, function_call{name, namespace?, arguments: JSON string, call_id} (exec_command {"cmd"}, wait, write_stdin, namespaced MCP), function_call_output{call_id, output: string ("…Process exited with code N…") | [{type:input_text,text}]}, custom_tool_call{name: exec (2187: a JS cell that calls tools, whose commands appear as thread items) | apply_patch, input}, custom_tool_call_output (Script completed|Script failed…) |
thread items when the file has any (one result per real command, clean user text), else response items |
User text is wrapped: Desktop prefixes requests with <in-app-browser-context source="ambient-ui-state">…</in-app-browser-context> and ## My request:; automations send <heartbeat>…</heartbeat> turns (turn_trigger: automation_heartbeat_scheduled); answers to the agent’s request_user_input arrive as <send_user_message_question_reply>[{question, answer}]</…>; response-item user messages also carry <environment_context>, <user_instructions>, the AGENTS.md preamble |
only the human’s words become input; heartbeats and frames are dropped; a question answer is an idle input with source: "question_reply" |
Turns: task_started{turn_attribution.turn_trigger: composer|exec|automation_heartbeat_scheduled|resume_interrupted_task|guardian_review}, task_complete{last_agent_message}, turn_aborted{reason:"interrupted"}; a UserMessage with the turn id of a running turn is a mid-turn steer (34 turns locally) |
agent_end at task_complete (yielded) / turn_aborted; the next input after an abort, and a steer, are interrupts (the extension’s !ctx.isIdle()) |
Usage: token_usage_record.usage (one per response) and event_msg/token_count.info.last_token_usage, both {input_tokens (includes cached), cached_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens, total_tokens} |
Usage{input: input − cached, cacheRead: cached, cacheWrite, output, totalTokens}; token_count only when the file has no token_usage_record (they duplicate) |
The skill list given to the model is in the rollout: world_state.state.host_skills.body and developer messages, a ## Skills block with ### Skill roots (- \r0` = `/abs/root`) and ### Available skills (- name: description (file: r0/x/SKILL.md)`) |
loaded entries are recoverable (18.2.2) |
Skill roots: REPO .agents/skills in every dir from cwd up to the repo root, USER $HOME/.agents/skills, ADMIN /etc/codex/skills, SYSTEM bundled (plus $CODEX_HOME/skills, seen locally); host roots are scanned recursively (loader/host.rs: SkillDiscoveryMode::Recursive unless the root belongs to an Agent Plugin, whose DirectChildren mode stops at depth 2; discovery.rs: MAX_SCAN_DEPTH 6), directory symlinks followed for user/repo/admin scope, hidden dirs below the root skipped (checked on the walked path, not the link target), a SKILL.md that is itself a symlink not discovered; no per-session or per-repo gating of user skills |
delivery is files (18.2.3): one directory link ~/.agents/skills/rematch → ~/.rematch/skills exposes every rematch/<name>/SKILL.md (no per-entry links needed); the entry dirs hold real SKILL.md files |
SKILL.md frontmatter is parsed into {name, description, metadata} without deny_unknown_fields; $name in user text mentions a skill (common env-var names excepted) |
staged SKILL.md files (with the rematch: block) load unchanged; $name tokens (lowercase) become skills_invoked |
codex exec --json prints thread.started{thread_id}, turn.started, item.started|updated|completed{item}, turn.completed{usage}, turn.failed, error; items are the thread items in snake_case (command_execution{command: string, aggregated_output, exit_code, status}, file_change{changes:[{path, kind}], status}, mcp_tool_call, agent_message{text}, reasoning, web_search, todo_list, error); no model name in the stream; --ephemeral writes no rollout |
parse_session accepts this stream too (18.2.4) |
18.2.2 Record mapping (import_codex::trace_records_of)
Section titled “18.2.2 Record mapping (import_codex::trace_records_of)”| Codex | Trace record (DESIGN §3) |
|---|---|
session_meta + first model + skill list |
session_start: session_id codex:<id>, cwd, repo (walk up to .git), branch_hint, model, mode: "import", agent main / sub (source.subagent.other), omp_version = codex <cli_version>, loaded_entries = ids of the offered skills that are rematch’s (the SKILL.md’s directory holds rematch.json — a link in the shared dir resolves to the staging or kept entry dir), skipping hook, agent and lesson entries (a hook’s router is offered but the hook never runs in Codex) and entries created after the session started; staging_dirs = their directories |
UserMessage (else event_msg/user_message.message, else response-item user message) |
input with redirect::prelabel (mark > interrupt > revert > negation > reprompt), after_deliverable = last assistant text, skills_invoked from $name / /skill:; slash/!/% lines other than /rematch mark and /skill: dropped as in §3 |
CommandExecution / exec_command, shell |
tool_result tool bash, input {command: <script>, cwd}, is_error = status failed/declined or exit_code ≠ 0 (response items: Process exited with code N, Exit code: N, legacy metadata.exit_code, Script failed), path / skill_read from a parsed_cmd read or skills/<name>/SKILL.md in the script |
FileChange / apply_patch |
tool_result tool apply_patch, input {paths, changes} (response items: {input: <patch>}, paths from *** Update File:), path = first file |
McpToolCall |
tool_result tool mcp__<server>__<tool>, is_error = failed, error, or result.isError |
ImageView, web search |
tool_result view_image (path), web_search (query) |
AgentMessage |
turn_end after the tool results that follow it: model, usage since the previous turn_end, stop_reason stop (final_answer) / toolUse (commentary), text_head; model_change first when the model changed |
task_complete / turn_aborted |
agent_end {turns, yielded} |
| end of file | session_shutdown with trace::flag_reasons (the extension’s rule) |
session_record = fold_trace of that trace with source: "import" and the rollout as source_path, so the ledger row, reflect --history, improve and count agree on seqs. import --history --harness codex writes the synthesized trace of every clean main session with loaded entries (traces_written), so count folds it into those entries’ counters; flagged sessions reach the counters through reflect --history (which writes trace + pending file). Real-history smoke (REMATCH_REAL_HISTORY=1 cargo test -p rematch-core import_codex): all 86 files parse; 17 reviewer threads; 352 inputs, of which the 217 in main sessions prelabel 137 none, 77 interrupt (inputs after an aborted turn, and mid-turn steers: 78 user messages arrived inside a running turn of one long Desktop session), 2 revert, 1 negation; 4064 tool results; 9 rows flagged.
Not recoverable from a rollout: what the model was shown beyond the skill list (rematch hooks never ran, so hooks_loaded is empty and no tool_blocked/retrieval record exists); turn_end.usage.cost (no price in the log); the provider-side model behind model_provider (openai unless configured); the skill set at session time when links changed before import (resolution reads the files at import time); calls inside a JS exec cell that are not thread items (custom_tool_call exec is one result in response-item files); source of an input (interactive vs. exec prompt) beyond question_reply; idle for a steer that Codex queued instead of injecting.
18.2.3 Delivery: skills sync --harness codex [--cwd <repo>]
Section titled “18.2.3 Delivery: skills sync --harness codex [--cwd <repo>]”- The shared sync of §16 “Shared skills”:
REMATCH_ROOT/skills/<name>for every kept/pinned entry and every provisional entry a synced cwd matched; the same links omp and Claude Code load. Codex reads them through the one link the user makes,~/.agents/skills/rematch → ~/.rematch/skills(or<repo>/.agents/skills/rematch, for one repo). - The envelope reports
reached_from: which of~/.agents/skills/rematchand<repo>/.agents/skills/rematchresolve to the shared dir; with none, a line gives theln -sthat makes Codex see it. - Migration from the per-harness layout of older versions: a
rematchlink in those roots still pointing atREMATCH_ROOT/codex-skillsis re-pointed (atomically) at the shared dir, and the kept copies under~/.agents/skills/<name>/marked.rematch-codex.jsonare removed (kept entries reach Codex through their links now);uninstallremoves such copies too.
Codex has no per-session gating: every link is visible in every Codex session, whatever its repo, until its entry is retired. Counting stays exact (count-time predicate on the imported session), so a link visible in another repo costs context but never a counter.
18.2.4 Batch mode (run --harness codex): plan, not built
Section titled “18.2.4 Batch mode (run --harness codex): plan, not built”Not built: the worker (batch/worker.rs), Substrate::rollout_cmd (returns omp::ToolsCall) and run_rollout (omp argv, omp::verify_stream) are omp-typed, and anchor replay reuses the omp binary; the change spans run.rs, rollout.rs, substrate.rs, worker.rs. The exact plan:
RunArgs.harness: omp|codex(default omp), carried in the job payload;Substrate::rollout_cmdreturns(program, argv, env);OmpSubstratekeeps today’s argv.CodexSubstrate::materialize:git worktree add --detach <variant>/wt HEADas today; the effective tree written to<variant>/wt/.agents/skills/<name>/(repo scope, so only this rollout sees it) with.agents/appended to<wt>/.git/info/exclude(the worktree’sinfo/excludepath fromgit rev-parse --git-path info/exclude) so verifiers diffing the worktree never see it;HOME=<variant>/home(empty, so~/.agents/skillscontributes nothing) andCODEX_HOMEleft as is (auth lives there; itsskills/and bundled system skills stay visible and are listed in the receipt as uncontrolled).- argv:
codex exec --json --ephemeral --cd <wt> --sandbox workspace-write --ignore-user-config -m <model> -- <prompt>with<model>=rematch-solverresolved for Codex (--modelonrun, else Codex’s default; recorded, not verifiable); envREMATCH=0,REMATCH_SEED; bound = task cap + 60 s throughproc::run_bounded(codex has no--max-time). - Stream check (replacing
omp::verify_stream): every non-empty line JSON, exactly onethread.started, ≥ 1turn.completed, noturn.failedorerrorevent; otherwise the rollout is invalid (verdict Fail “rollout invalid” without running the verifier), a time-capped run is still judged. - Trajectory:
import_codex::parse_session(stdout)+trace_records_of(unit-tested on anexec --jsonstream), worktree path rewritten to the repo,loaded_entries= the materialized tree’s ids, model = the-mvalue. - Verifier, receipts, next-run rule and anchor replay unchanged (anchor replay runs with the harness of the run that triggered it).
18.3 Pi (adapters/pi/, rematch_core::import_pi)
Section titled “18.3 Pi (adapters/pi/, rematch_core::import_pi)”Target: @earendil-works/pi-coding-agent 1.4.0, verified under Node with a local scripted model. The retired @mariozechner/pi-coding-agent package is not the tested target. Exact API sources and runtime observations: adapters/pi/API-NOTES.md.
- Install from the checkout with
pi install /path/to/rematch/adapters/pi; for a single session usepi -e /path/to/rematch/adapters/pi. This is not a published npm artifact: imports of shared helpers stay relative to the checkout. session_startruns boundedmatch/skills sync; nativeresources_discovercontributesREMATCH_ROOT/skills. No omp settings-registry override is used.- Native input, tool-execution, turn and settlement events record evidence. Extension-generated inputs do not become user corrections; successful skill reads are distinguished from failed reads.
/rematchand the model’srematchtool share the CLI bridge. Approval and other user-only controls are refused by the model tool; digest confirmations are user interactions. This is a control-plane restriction, not an OS sandbox.before_agent_startappends the approved prompt fragments ofmatch(prompt_fragments) to the system prompt of main sessions, rendered as in §16 “System prompt”.- Approved block/stuck rules run before tool calls. Approved retrieval/truncation act on outgoing context only. Pi converts the hidden retrieval message to a user-role provider message; it is never counted as a human input.
- Runtime reload continues an unfinished trace; resuming a closed session creates
pi:<id>:r<n>. Forked/copied history is context, not new evidence. - Pi’s
PI_CODING_AGENT_DIRmust not redirect Rematch’s omp backend. Child calls pass the Pi paths inREMATCH_PI_AGENT_DIR/REMATCH_PI_SESSION_DIR, leaving omp model-role resolution separate. - History import reads Pi’s native session files via
import --history --harness pi. Native Pi has no subagent-definition surface; agent entries are not loaded or counted there. Reflection and verifier-driven batch rollouts still require omp.
18.4 OpenCode (adapters/opencode/, rematch_core::import_opencode)
Section titled “18.4 OpenCode (adapters/opencode/, rematch_core::import_opencode)”Target: opencode-ai and @opencode-ai/plugin 1.19.4. The plugin uses the local package directory’s exports["./server"]; configure plugin: ["/path/to/rematch/adapters/opencode"] without replacing other config. This is checkout-based support, not an npm publication or a claim about OpenCode 2.
Verified API and test evidence: adapters/opencode/API-NOTES.md.
- The plugin adds
REMATCH_ROOT/skillsto in-memoryskills.pathsand registers/rematch-*commands. It does not rewrite user configuration. chat.messageprovides human prompts; synthetic parts and command expansions are excluded. Tool-part state transitions supply successful and failed results, including thrown tools and nonzero bash exits. A successful nativeskillcall records skill activity.- Approved block/stuck rules run through
hook-checkbefore tools. Retrieval usesexperimental.chat.system.transform, appending units to the outgoing request’s system content without manufacturing a user input. Truncation usesexperimental.chat.messages.transform; stored history is unchanged. The same system transform appends the approved prompt fragments ofmatch(prompt_fragments), rendered as in §16 “System prompt”. - The model tool is read-only. User approval commands remain explicit; the direct shell-command guard is a pattern check, not an execution sandbox.
- Idle is not session end. A main trace finalizes at last-owner disposal, reconciliation after owner-process death, or six hours quiet with no running turn. A later continuation starts
oc:<id>:r<n>; a counted segment is not mutated. Parent-written child prompts do not count as human corrections. - Native history is read from OpenCode’s SQLite store read-only, via
import --history --harness opencode. Prefix routing covers reflection, improvement, counters and lake export. - No generated agent-profile renderer, live subagent tracing, approval dialog or OpenCode batch solver is shipped. Reflection continues through omp; an absent omp backend does not block live tracing.
- Acceptance used the real pinned OpenCode CLI with an isolated HOME/XDG/Rematch root and a local scripted model: plugin loading, shared native skill use, failures, approved blocking, retrieval, resume/crash finalization and counter idempotence. Stuck and truncation policy edges have deterministic tests; the TUI toast and cloud-provider inference were not exercised.
19. The lake (ADR 001)
Section titled “19. The lake (ADR 001)”History becomes a queryable backend: an append-only journal in the ledger records every write (sessions, redirects, entries, counters, proposals, receipts, outcomes) with the ledger’s own clock as known; rematch lake export writes the journal to Parquet under REMATCH_ROOT/lake/<table>/ (one Bellwether root per table, per-run files, never rewritten); rematch lake index builds Bellwether indexes under REMATCH_ROOT/lake-index/<table>/ (per machine, never synced); rematch lake query plans through Bellwether and evaluates the residual in rematch. The hot path never reads the lake. Keys are declared only for questions a current reader asks (report, why --asof, create --from sessions, improve, transfer suggest); the rest of Bellwether’s kinds are declined by name in the ADR with the question that would justify each. Identity that survives machines: repo_remote + subpath on predicates, content_sha256 on entries, versioned loaded_entries (L1, built: journal and meta in §13, trace fields in §3, place matching and content_sha256 in §6; rematch lake migrate fills them on rows written before them: content_sha256 on every entry lacking it, repo_remote/subpath on predicates whose repo glob names a worktree on this machine (the glob stays) and on sessions whose repo exists here; idempotent, file reads only). Full design, key table, bitemporal semantics, build plan L1–L4 and the review record: docs/adr/001-lake.md.
19.1 Lake files and commands (L2, crates/rematch-lake, crates/rematch/src/lake_cmd.rs)
Section titled “19.1 Lake files and commands (L2, crates/rematch-lake, crates/rematch/src/lake_cmd.rs)”Built behind the cargo feature lake of the rematch package (default on; arrow/parquet 59.3, the versions Bellwether pins; snappy pages). cargo build --no-default-features builds the binary without arrow. Nothing in a session reads or writes the lake: match, skills sync, trace-event, hook-check never open it (match ≤ 50 ms release, test bound 100 ms on the debug binary).
lake/manifest.json {lake_version: 1, bellwether_version_pinned: null until the first `lake index` (§19.2), watermarks: {machine: last exported journal seq}, events_exported: {session_id: true}, events_missing: [session_id]}lake/events/{harness}/{day}/{session_id}.parquet one file per closed session: its trace when traces/<id>.jsonl is closed (a session_shutdown record), else (imported history) the records `reflect --history` synthesizes from the harness transcript; harness omp | claude-code (cc:) | codex (codex:) | pi (pi:) | opencode (oc:); day = UTC date of session_start.t (a ledger-only session: started_at); the file name is `paths::encode_file_stem(session_id)` on every platform (`cc:<uuid>` → `cc%3A<uuid>`; `%`, `<>:"/\|?*`, control chars, a leading `.`, a trailing `.`/space and a Windows device name are `%XX`), so a lake copies between Unix and Windows unchangedlake/{table}/{day}/{machine}-{seq}.parquet sessions redirects entries counters proposals receipts: journal rows of one export run; day = export UTC day; seq = journal seq of the run's first row of that table (unpadded)lake/outcomes/{domain}/{day}/{machine}-{seq}.parquet the same, split by subject.domainlake/blobs/<sha256> proposal replies and receipt certificates the exported rows point atFiles are written to .<name>.tmp-<pid> and renamed, never rewritten (an existing path is skipped) — with one exception: an events file exported before the tool_cmd column existed is rewritten once, by lake index (events::upgrade: the same records as a fresh export writes them now, so the bytes equal a new export’s), because Bellwether reads one schema per root and refuses a key on a column some file lacks (column "tool_cmd" not in source schema); a scan before that derives the column from tool/tool_input. Partition values (harness, day, session_id of events; day; domain of outcomes) live only in the path. Time columns are Timestamp(ms, "UTC"), lists List<Utf8> (non-null items), statistics untruncated (statistics_truncate_length(None), column_index_truncate_length(None)). Same journal rows ⇒ byte-identical files.
Events file names before the portable codec. Exports before it named a session’s file by its raw id (events/claude-code/<day>/cc:<uuid>.parquet; /, \ and NUL as _, a leading . prefixed _). Nothing is renamed or rewritten: such a file stays where it is and is still found (events::find_events_file tries the encoded name, then the raw one; Windows never holds a raw name with :), still settles its session (lake export writes no second file next to it), and reads back with the right session_id (events::session_of_file decodes only a stem that is exactly an encoding, so cc:<uuid> and a literal a%41 stay as they are and nothing is decoded twice). lake import keys events by (harness, decoded session_id) and writes every copy under the encoded name, so a pre-codec Unix lake imports onto Windows and a session already present under either name is skipped. session_id in a Bellwether index of a pre-codec lake is the raw name for old files and the encoded one for new files; lake query rows carry the decoded id either way.
Columns. Every append table starts with seq (Int64, journal seq), known (ts, ledger clock of the write), machine, op (insert/update/snapshot), row_key and ends with json (the record as written, compact JSON):
| Table | Typed columns (between row_key and json) |
|---|---|
sessions |
session_id, repo_remote, subpath, domain (§6 “Domain”; null on rows written before it and in files exported before the column existed — the column is additive, readers take columns per file), started_at ts, ended_at ts, family, loaded_entry_ids list (the record’s loaded_entries), loaded_entry_shas list, visible_entries list, skill_reads list, exts list, stems list, redirects_json, flagged bool, counted bool |
redirects |
session_id, redirect_seq Int64 (the redirect’s trace seq; seq is the journal’s), prelabel, class, confidence f64, text_head, t ts |
entries |
entry_id, status, version Int64, content_sha256, predicate_hash, name, branch, valid ts (status_since, else created_at) |
counters |
entry_id, session_id, content_sha256, predicate_hash, family, matched, recurrence, harm, tokens, turns (Int64), ts ts |
proposals |
id, entry_id, session_id, blob, lint_ok bool, created_at ts |
receipts |
id, entry_id, certificate_hash, activation, created_at ts |
outcomes |
id, session_id, subject_id, signature, claim, facets list (domain=<each dotted prefix>, key=value, bare key), valid ts (ts), result_status |
events columns: seq Int64, t ts, ev, tool, call_id, tool_input (JSON of input), args_hash, result_bytes Int64, is_error bool, path, skill_read, text, prelabel, text_head, usage_json (JSON of usage), stop_reason, reasons list, flagged bool, extra (JSON object of every other field of the record, nulls included; a field whose value does not fit its column’s type goes here too; null when none), tool_cmd (derived, not a trace field: input.command of a tool == "bash" record, else null; every harness’s shell tool is bash in the trace, §3; events::read_records never returns it).
Commands. lake export [--all]: journal rows with seq > this machine’s watermark (rows of other machines, present only after a rebuild, are skipped), one run of ≤ 50 000 rows and ≤ 64 newly settled sessions, --all drains both; then the watermark moves to the last row of the run. Sessions go in session-id order: every closed trace file, then every ledger session without one, its records from import_cmd::records_for_session (the binary passes that converter to the lake crate as an EventSource); a ledger session with no transcript left is added to events_missing (it counts toward the 64, has no file, and is retried only by --all). lake import --from <lake | REMATCH_ROOT>: copies files whose (table, machine, seq) (events: (harness, session_id)) is absent, and absent blobs; watermarks merge by max per machine; lake-index/ is outside every lake and never read. lake rebuild [--from <lake>] [--force] [--machine <m>]: replays one machine’s rows (default: this machine’s id) in seq order through Ledger::apply_journal_row into ledger.sqlite.rebuild-<pid>, copies the lake’s blobs, then moves the result to ledger.sqlite; refuses a ledger holding any journal, entry or session row unless --force (the old file is kept as ledger.sqlite.pre-rebuild-<ms>). lake doctor: machine, journal_max_seq, watermark, lag = max seq − watermark, files per table, events, blobs, events: exported N, missing M, pending P (pending = ledger sessions in neither manifest set), orphans (temp files, names off the layout, files past their machine’s watermark), the op = snapshot range (rows that predate the journal: state at migration, not transitions). Export, import and rebuild hold REMATCH_ROOT/.lake.lock.
19.2 Indexes and queries (L3, crates/rematch-lake/src/{index,query}.rs)
Section titled “19.2 Indexes and queries (L3, crates/rematch-lake/src/{index,query}.rs)”Bellwether is an external binary, optional and never linked: --bellwether <path>, else $REMATCH_BELLWETHER, else bellwether on PATH. Without it lake index fails naming the three, and lake query reads every file.
lake index [<table>…] [--repin] [--bellwether P] (holds .lake.lock; default all tables) runs, per table root with files, bellwether index --json lake/<table> --index lake-index/<table> --path-template <T> --key <spec>… (incremental: Bellwether reads only new or changed files). Every spec below was accepted as written by bellwether 0.1.0 and is the identity under which its manifest records completeness (key_spec_completeness; the temporal2 sidecar under its name):
| Table | --path-template |
--key |
|---|---|---|
events |
{harness}/{day:date}/{session_id}.parquet |
exact:tool_cmd,counts, text:tool_cmd,tokenizer=words, exact:path, exact:skill_read |
sessions |
{day:date}/{machine}-{run_seq:int}.parquet |
exact:repo_remote,counts, exact:domain, list:loaded_entry_shas, list:exts, list:stems, range:started_at |
redirects |
the same | temporal:session_id,redirect_seq@known, exact:class |
entries |
the same | temporal2:entry_id@valid,known (a storage-version-1 sidecar), exact:content_sha256 |
counters |
the same | temporal:content_sha256@ts |
proposals, receipts |
the same | exact:entry_id |
outcomes |
{domain}/{day:date}/{machine}-{run_seq:int}.parquet |
exact:signature, temporal2:subject_id@valid,known, list:facets |
Adaptations, each forced by the binary: (1) the ADR’s {machine}-{seq:int} placeholder is {run_seq:int}, because the files store seq per row and Bellwether refuses a placeholder whose stored column disagrees with the path: row group 0 stores column seq and holds more than one value of it (its min and max differ), so it does not show the value its path gives (seq=5); a file whose path and content disagree, or whose statistics cannot show they agree, is refused. machine is stored and in the path; one run file holds one machine, so they agree. (2) The ADR’s temporal:session_id,seq@known names the stored redirect_seq (the journal’s seq is another column). Path placeholders become row columns (day Date32, run_seq Int64, harness, session_id, domain, machine strings). Bellwether never lists a path component starting with . or _ (an empty outcome domain is written as _): lake index reports such files as unindexed_files, and lake query on that table scans.
Version pin: the first index writes bellwether --version (0.1.0) to lake/manifest.json bellwether_version_pinned. Another version: lake index refuses (the lake's indexes were built by bellwether A and this binary is B; run rematch lake index --repin …), lake query scans with that reason, lake doctor reports it as index_refused. --repin deletes every lake-index/<table> and rebuilds all tables under the new version; the lake files are never touched.
lake query <table> --key K (--values v1,v2… | --has w1,w2…) [--from --to] [--valid V] [--known K] [--latest] [--all] [--limit n] (instants: ms UTC, RFC 3339, or YYYY-MM-DD); rematch_lake::query::run(root, &QuerySpec, bin) for readers. K is a key above by its spec or short name (exact:tool_cmd, text:tool_cmd, exact:path, list:stems, temporal2:entry_id, temporal:content_sha256, temporal:session_id,redirect_seq with values sid:seq or sid\tseq). A text key takes --has (every word required; each argument is split at Unicode word boundaries after case folding, rematch_core::words, the tokenizer Bellwether’s words uses at the versions it pins — unicase 2.10.0, unicode-segmentation 1.13.3 — so --has "cargo test" = --has cargo,test; an argument with no word is a usage error); every other key takes --values, whose values go to a Parquet key file (lake-index/.keys-<pid>-<n>.parquet, deleted after) whose columns are the key’s columns at the dataset’s types (list: one Utf8 column named by the list column), then:
| Key kind | Request | Bellwether |
|---|---|---|
exact, temporal |
values [+ window] | locate --keys F [--from --to --ts <window column>] |
text |
words (--has) |
one locate --has <column>=<word> per word (each answers has_token exactly at row-group grain: exact_row_groups: true); the planned row groups are intersected across words, every plan’s residual applied; --has “cannot be used with –from/–to/–ts” (window applied by rematch); plan.lookup is then the list of lookups and plan.planned the intersection {files, row_groups} |
list |
values, --all or any |
locate --keys F --all / --any (which “cannot be used with –from/–to/–ts”; window applied by rematch) |
temporal |
--latest / --valid V |
latest / asof --at V |
temporal2 |
--latest [--known K] / --valid V --known K |
latest --known K (i64::MAX when absent; latest without it needs a temporal key) / asof --valid V --known K |
temporal2 |
values only (every version) | none: the binary refuses locate on the sidecar’s column (“none of the key specs … serves a lookup on [entry_id]”), so a scan |
range:started_at |
window only | none (locate needs keys), so a scan; it prunes exact:repo_remote windows |
A window is on the table’s time column (events t, sessions started_at, redirects known, entries/outcomes valid, counters ts, proposals/receipts created_at), inclusive, and never combines with --latest/--valid/--known. The plan consumed is the --json line’s evidence.record.body.plan: entries[{file:{path (canonical), size, etag, tail_blake3}, row_groups:[in-file index…], bytes}] and residual, a tagged tree ({op: and, all:[…]}) of in {columns, values}, between {column, lo, hi}, not_older_than {columns, ts_column, bounds:[[key, ts]…]}, latest_per_key {columns, ts_column}, text {column, operator: has_token|contains|has_prefix, values} (values as given, folded by the consumer), contains_any {column, values}, contains_all {column, values:[[group…]…]}, every other value a canonical encoding (Bellwether ADR 016) as a JSON byte array. rematch encodes each row’s cells the same way (Utf8 06 … 00 00, Int64 02 + sign-flipped big-endian, Timestamp(ms) 08 04 02 + Int64, Date32 08 01 05 + Int64, Bool 01 b, Float64 04 + sign-aware flip) and compares bytes, so between is byte order. Observed shapes: locate on exact → in; --has → text {operator: has_token, values: [word]}; on list → contains_any/contains_all; exact/temporal + window → and[in, between]; latest → and[in, not_older_than, latest_per_key]; asof --at → and[in, between, not_older_than, …]; temporal2 → and[in, between(valid), between(known), not_older_than(valid), not_older_than(known), latest_per_key(valid)]. Any other op (semi_join, join, window_partition, expr_in) is refused naming it, never ignored. latest_per_key keeps every row at its key’s greatest time (ties kept, as the plan keeps every row group at the winning time); a null time never wins. text folds the row’s value and each argument (has_token: one of the value’s words equals it; has_prefix: one starts with it; contains: a substring); a null never matches. The planned row groups are read with the parquet reader (with_row_groups) and rows sorted by (file path, row number in the file), so an indexed answer and a scan answer are the same list.
Scan (indexed: false, reason says why): no binary; no lake-index/<table>/manifest.json; a pinned version the binary does not match; files Bellwether does not list; a lookup no key serves (above). The scan reads every file of the table and applies the same predicate locally (in/text/contains_*, the window, and for --latest/--valid/--known per key: times ≤ the instants, then the greatest valid, then the greatest known at it). Before an indexed lookup, a table whose files the index does not track (new, resized, gone), or whose index was built before the key asked for was declared, is re-indexed (reindexed: true): the ADR’s “when lake query finds a root with files newer than its manifest”. lake index (and that re-index) first upgrades pre-tool_cmd events files (above; upgraded: n per table) and removes an index root whose manifest records a key the table no longer declares (an expr:tool_cmd index from before the stored column; Bellwether keeps every spec an index was built with and refuses an expr name equal to a dataset column), rebuilding it from scratch (rebuilt: true). Each Bellwether lookup also writes an evidence record under lake-index/<table>/ledger/ (the index is per machine and rebuildable).
lake doctor [--bellwether P] adds index: the binary and its version against the pin (index_refused), per table whether it has an index, its untracked/gone/skipped files and each key’s completeness, and sessions_without_counters: session ids whose newest sessions row is counted with loaded entries and that have no counters row anywhere in the lake (the attribution anti-join the ADR keeps out of the index).
19.3 Readers and report (L4, crates/rematch/src/{lake_read,report}.rs)
Section titled “19.3 Readers and report (L4, crates/rematch/src/{lake_read,report}.rs)”Historical readers read the lake when it exists (lake/manifest.json present, binary built with lake) and fall back to today’s path otherwise or when the lake cannot be read; every one names its source in its envelope ("source": "lake"|"ledger"). Readers of journal tables also fall back while the lake lags this ledger (ledger.journal_max_seq() > manifest.watermarks[machine], a line on stderr): an export lag never hides a local write. Rows from either side are journal rows (JournalRow: machine, known, seq, row_key, the record as json), read through lake_read::rows — the lake through rematch_lake::query::run with the key below (indexed, else the same rows by scan) or query::scan, the ledger from its own journal table — and deduped by lake_read::as_of(rows, K): per (machine, row_key) the row with the greatest known ≤ K, ties by seq (ADR 001 §“Bitemporal semantics”). A lookup only narrows the lake’s rows; each reader applies its own filter to either side.
| Reader | Today’s path (fallback) | With the lake | Envelope |
|---|---|---|---|
reflect --history |
import_cmd::read_transcript (subagent from the transcript) |
events/<harness>/<day of started_at>/<sid>.parquet by path, events::read_records → trace records; subagent = session_start.agent.kind == "sub"; per session, a missing or unreadable file falls back |
history.source (lake when any session came from it), history.sources {lake, ledger} |
improve excerpt + clusters |
import_cmd::records_for_session (trace, else transcript) |
the same events file first (import_cmd::session_records) |
source, sources |
create --from sessions reference search |
every ledger session | sessions list:stems ∪ list:exts contains_any (every machine), latest row per session, started_at order, then the same overlap scoring; reference excerpts from the events file, else the trace |
source; the “found N matching session(s)” refusal names it |
transfer suggest facet keys |
outcome_facets ⋈ outcomes |
outcomes latest row per outcome (every machine): facets[].key per branch (the bare keys of list:facets) |
source (the session-start digest’s suggestions stay on the ledger: it never reads the lake) |
library why <sid> --asof <ts> |
the ledger’s journal (entries by entry id, redirects by <sid>#<seq>) |
entries temporal2:entry_id + redirects temporal:session_id,redirect_seq (every version, then as_of) |
source, asof {ts, utc}, per loaded entry asof {status, version, content_sha256} (null: not written yet), per redirect asof {recorded, class} |
report |
the ledger’s journal |
entries, counters, redirects by scan, sessions range:started_at over the window; the lake alone, at any lag (reported) |
source, lake_lag |
tune (and the pass inside count) |
the ledger’s journal (sessions, each its latest row) |
sessions range:started_at over the window, only while the lake holds every journal row of this ledger (inside count the lake always lags, so the ledger); a session’s retrieval records from its events file, else its trace |
source (also in each proposal’s evidence.source) |
--asof takes ms, RFC 3339, YYYY-MM-DDTHH:MM[:SS] (UTC) or YYYY-MM-DD (00:00 UTC); without it why is unchanged ("source": "ledger"). library why reads only this machine’s rows (a ledger session’s entries are this machine’s ids).
report [--window 6w] [--since YYYY-MM-DD] (<n>w|d|h; window [now − window, now], or [since 00:00 UTC, now]): every table as of the window’s end K = now; redirect labels are the redirects rows as of K over the session row’s redirects.
- Kept over ≥ 4. Each entry whose status at
Kiskept: the distinct sessions of counter rows withtsin the window andmatched > 0that are its own (machineandentry_id) or carry itscontent_sha256(any machine);reached= ≥KEEP_MIN_MATCHED(4); recurrence and harm summed over those rows. - Recurrence per trigger. Non-draft entries grouped by
predicate_hash, except a predicate tune’s sibling (Entry.tune, §6 “Predicate tuning”), which joins the group of the entry its lineage started from; the trigger’s predicate is that of its latest-updated entry, its class the first non-noneredirect_classbycreated_at(none: correction or preference,counters::class_recurs, the rulerecurreduses). A session started in the window is in the with arm when it loaded a member of the trigger (on the member’s own machine byloaded_entriesid — a tune sibling shares its parent’scontent_sha256, so only the id tells the versions apart —, on another machine byloaded_entry_shas) and the predicate of a member it loaded hits (count_match(predicate, Place::of_session, exts, stems)), so a widened sibling never counts a session against the parent’s predicate and vice versa; it is in the before arm when it loaded none and the trigger’s predicate hits. Per arm:sessions,recurred(sessions with ≥ 1 redirect of the class),redirects(their count),rate = recurred / sessions(null on no session);delta = with − before;covered= with-arm non-empty;fell=delta < 0. - Predicate tunes. Every entry whose
tune.atfalls in the window, with its parent: the parent’s with-arm (sessions that loaded the parent and its predicate hit), the sibling’s with-arm (loaded the sibling, its predicate hit) and the before-arm of the parent’s predicate (loaded neither), side by side, so a widening is read against the version each session loaded. JSONtunes [{machine, name, at, direction, parent {id, version, status, predicate_hash, predicate}, tune {…}, added, removed, added_exts, domain_set, evidence, redirect_class, v1, v2, before}]. - Verdict:
verdict: an entry reached kept over ≥ 4 matched sessions: yes (<names>)|no; recurrence fell on a covered trigger: yes (<triggers>)|no; built-in entries (library::is_builtin, every entry of the trigger) are listed ((built-in)) and never count. JSONverdict {kept_over_min, kept_entries, recurrence_fell, fell_on}.
Text lines: a header (source, lag, machines, window), one kept … line per kept entry (or kept: none), then per trigger domain (the trigger predicate’s domain, (none) without one; domains in name order, only those with a matched session) a summary domain <d>: <n> trigger(s), <c> covered, <f> fell · with … · before … (arms summed over its triggers: a session matching two triggers counts twice) followed by its indented trigger … lines, one per trigger with a matched session (with i/n recurred (r redirect(s), rate x) · before …[ · Δ d[ fell]]), one tune <name> v<a>→v<b> (<at>, <status>): <note> · v<a> with … · v<b> with … · before … line per tune, the verdict. JSON: each trigger carries domain; domains [{domain, triggers, covered, fell, with, before}].
20. Profile (rematch_core::profile, crates/rematch/src/profile_cmd.rs)
Section titled “20. Profile (rematch_core::profile, crates/rematch/src/profile_cmd.rs)”One person’s omp setup — what the loop kept, what they wrote by hand, what they approved and configured — as a git working tree at REMATCH_ROOT/profile/, synced across machines through any git remote. omp formats only (no renderers for other harnesses); everything is linked into the places omp and rematch already read, so neither needs to know the profile exists.
profile/ skills/<name>/ kept skill entries (the keep writes here) and adopted hand-written skills; <agent_dir>/skills/<name> → here agents/<name>.md kept agents' AGENT.md copies and adopted hand-written agents; <agent_dir>/agents/<name>.md → here fragments/<name>.md AGENTS.fragment.md of kept entries whose dir is not under skills/ (kept agents) hooks/<id>.ts approved compiled hooks; <agent_dir>/extensions/rematch-hooks/<id>.ts → here config/policy.json REMATCH_ROOT/policy.json → here config/roles.yml {<role>: <selector> | {default: <selector>, <hostname>: <selector>}} config/domains/<branch>.yml REMATCH_ROOT/branches/<branch>/domain.yml → here improver/<file> improver files the user edited (hash ≠ improver/.shipped/<file>); improver/<file> → here profile.json {"version": 1, "machines": [hostname…]} .gitignore .conflicts, skills/*/rematch.json, skills/*/EXPERIENCE.md, temp files .conflicts paths the last sync could not rebase (per machine, ignored) .index.json per skills/<name>: {machine, at, commit} of the last commit touching it (`git log -1 --format=%s -- skills/<name>` = "<machine> <ts>"), written by every sync (per machine, ignored)Per machine, never in the profile: the ledger and its journal, the lake and its indexes, staging/ (provisional entries), drafts/, archive/, predecessors/, traces, pending files, jobs, credentials and omp’s own config (concrete model selectors only through roles.yml), and inside skills/<name>/ the ledger mirror rematch.json (absolute paths, this ledger’s state) and the reflector’s EXPERIENCE.md. History is the lake’s (§19), not the profile’s.
Present = profile/.git exists. Without it nothing changes anywhere.
Keep path. library::dir_for(kept|pinned) of a non-agent entry is profile/skills/<name>/; transition links <agent_dir>/skills/<name> → it after a keep and removes that link when the entry leaves kept (only while it points at the entry’s dir). promote_into treats what holds <agent_dir>/skills/<name> (a dir kept before the profile, a user’s skill or a foreign link) as the replaced occupant (predecessors/<name>/<version>/); rollback restores into the profile and relinks. Kept agents keep their entry dir in kept-agents/<name>/; write_kept_agent copies AGENT.md to profile/agents/<name>.md and links <agent_dir>/agents/<name>.md to it (remove_kept_agent removes both). approve of a hook writes profile/hooks/<id>.ts and links rematch-hooks/<id>.ts; remove_hook removes both. scope::anchor finds the replaced kept entry at either location (library::is_kept_slot).
A keep whose name the profile already holds from another machine (a dir without this ledger’s rematch.json) replaces it like any occupant: the other machine’s version is saved as this machine’s predecessor version 0, and the next sync carries the new version to every machine (last keep wins; rollback here restores it).
Kept sources. library::sync_shared_skills adds every profile/skills/<name>/ with a SKILL.md (profile::kept_sources) to REMATCH_ROOT/skills/ for names no ledger entry holds, so entries kept on another machine and adopted skills load like this machine’s kept entries. They are files, not ledger entries, on machines that did not keep them: they load but are counted only by the machine whose ledger holds the entry, and a hook linked from another machine runs only where its entry is known (built-in entries have per-machine ids and are approved per machine).
Profile-kept. profile_cmd::profile_kept: the kept sources no entry of this ledger holds (no entry’s path is the dir) — kept or adopted on another machine. library ls lists each after the entries as kept (profile, from <machine>) skill <name> [profile] not counted on this machine (JSON profile_kept [{name, status: kept, source: profile, from, path, counted: false}]), library why as kept (profile, from <machine>) <name>: linked into the shared skills dir, not counted on this machine[ · read in this session] (profile_kept[].read from the session’s skill_reads), and report (which already excludes them: they have no counters here) adds one line after its header, profile-kept (not counted on this machine): N (JSON profile_kept). The machine comes from .index.json, else (a name the last sync did not see) from the same git log; unknown without either.
omp only. profile adopt refuses another harness’s files with <path> is <why>: the profile holds omp skills and agents only (a dir with SKILL.md, an agent .md); CLAUDE.md, settings and other harnesses' files stay out: anything under Claude Code’s config dir ($CLAUDE_CONFIG_DIR, else ~/.claude) or Codex’s ($CODEX_HOME, else ~/.codex; ~/.agents), inside any .claude/.codex/.agents dir, and CLAUDE.md, CLAUDE.local.md, AGENTS.md, settings.json, settings.local.json anywhere; paths under the omp agent dir are always accepted. Claude Code and Codex reach the same skills through REMATCH_ROOT/skills (§16), never through the profile.
Commands.
| Command | Behavior |
|---|---|
profile init [--remote <url>] [--adopt] [--timeout 10] |
git clone <url> (bounded; an empty remote or one whose HEAD names a missing branch checks out main, else master, else its first branch, else starts main) or git init on main; an existing profile only gains origin when it has none. Then the layout and .gitignore lines, --adopt, and one sync. --adopt: each kept/pinned entry rematch owns moves into the profile (library::adopt_kept: the dir with its ledger path and rematch.json, or the kept agent’s copy), then every remaining real dir in <agent_dir>/skills/ with a SKILL.md and every regular <agent_dir>/agents/*.md whose rematch: block names no entry of this ledger is adopted as below. |
profile adopt <path> |
A skill dir (with SKILL.md) moves to profile/skills/<name>/, an agent file to profile/agents/<name>.md; a link takes its place. A kept entry rematch owns moves as with --adopt; a skill registered before keeps its entry with the new path; any other skill is registered (improve::register, branch shared) as a pinned entry (status and flag, origin import:profile): measured, never moved or replaced by the loop. Refused for a link, a name the profile holds, an invalid name. Then the views. |
profile sync [--no-push] [--timeout 10] |
Under REMATCH_ROOT/.profile.lock (another sync running ⇒ skipped; a lock older than 5 min is taken over): collect (a regular policy.json, branches/*/domain.yml, edited improver files and hooks approved before the profile move in, links left; a name the profile already holds stays local, kept_local), fragments, profile.json machines, git add -A + commit "<hostname> <UTC ts>" when dirty; with origin: git fetch then git rebase origin/<branch> (no autostash; the tree is committed). A rebase that stops on conflicts is aborted — the tree is back at the local commit, clean — and the paths are returned as conflicts and written to profile/.conflicts (cleared by the next sync that rebases cleanly; kept while offline). Then the views (profile::link_views) and the shared skills dir, then git push origin HEAD:refs/heads/<branch> when ahead and nothing conflicted. Fetch and push share --timeout seconds (each at least 1 s); a failure or timeout is offline: true with error, never a failed command. Returns {machine, branch, remote, committed, pulled, pushed, offline, error, conflicts, collected {moved, kept_local}, fragments, views {linked, pruned, untouched}, skills {added, pruned, total}, elapsed_ms, lines}. |
profile status |
No network: {present, dir, machine, branch, remote, head, ahead, behind (against the last fetched origin/<branch>), dirty, conflicts, machines, skills, agents, hooks, roles (this machine's roles.yml selectors), lines}. |
profile install-launchd [--interval 300] |
Writes ~/Library/LaunchAgents/com.vncp.rematch.profile-sync.plist running <rematch> profile sync (a full sync: pull and push) every interval seconds and at load, with HOME and the set ones of PATH, REMATCH_ROOT, PI_CONFIG_DIR, PI_CODING_AGENT_DIR, OMP_PROFILE, PI_PROFILE, REMATCH_MACHINE, output to REMATCH_ROOT/profile-sync.log; the binary is ~/.local/bin/rematch when it exists, else the running one. Prints launchctl load -w <plist>; never loads it. |
Views (profile::link_views, idempotent). Each profile item is linked where its reader looks: created when absent, re-pointed when it is a link into the profile or dangling; a real file or dir, or a link elsewhere, is the user’s and stays (untouched). A link whose profile target is gone is pruned. An improver file that still equals its .shipped hash gives way to the profile’s edited copy; a local edit stays. install_improver then sees the link’s content as the user’s edit (its hash differs from the shipped record) and leaves it.
Roles. config::resolve_chain asks profile::role_selector for each role of the chain before config.yml: key = the role or the role without rematch- (editor = rematch-editor), value a selector or {default, <hostname>} (hostname = $REMATCH_MACHINE, else gethostname); doctor reports via profile:<role>.
Session start (§10): the extension runs profile sync --timeout 8 in the background once per process, only when match reported profile_present. Digest: one profile: N conflict(s) not synced (<paths>); resolve with git -C <profile> pull --rebase, then rematch profile sync line while .conflicts lists any. Uninstall leaves profile/ (the user’s repo) in place and removes every other child of the root, the links into the profile (<agent_dir>/skills/<name>, <agent_dir>/agents/<name>.md, rematch-hooks/, policy.json, domain.yml, improver links) and the launchd plist; an adopted hand-written skill or agent (no rematch: block) is restored as a copy where its link was.