How the knowledge pipeline works¶
The knowledge pipeline is the path from "a session just happened" to "six months from now I can ask a semantic question about it and get the right answer". It is the long-term arm of the memory model.
Four moving parts feed it:
session-export— writes clean markdown sessions into the store'ssessions/.compound-loop— writes distilled learnings into the store'slearnings/.- QMD (optional external tool) — indexes the knowledge tree semantically and exposes a
qmd queryinterface. - The knowledge store itself — a plain filesystem tree, and a git repository, that the user can backup, grep, and open in any editor.
See ADR-011, ADR-016 for the design decisions. This page explains the mechanics.
Store shape¶
<store root>/ # $LAZY_KNOWLEDGE_ROOT, [knowledge].root, or the default
├── knowledge.toml # the marker: declares the three area names
├── sessions/ # written by session-export hook
│ ├── 2026-03/
│ │ ├── 2026-03-01-a1b2c3d4.md
│ │ ├── 2026-03-02-f5e6d7c8.md
│ │ └── ...
│ └── 2026-04/
│ └── 2026-04-13-9a8b7c6d.md
├── learnings/ # written by compound-loop worker
│ ├── 2026-03/
│ │ └── 2026-03-15-file-based-queue-is-enough.md
│ └── 2026-04/
│ └── 2026-04-13-symlinks-vs-copies-for-profile-deploy-laptop.md
└── memory/ # per-project distilled memory, keyed by git remote
└── github.com/
└── <owner>/
└── <repo>/
├── MEMORY.md
├── decisions.jsonl
├── failures.jsonl
└── ...
The marker is the only declaration of the layout¶
[knowledge]
version = 1
sessions = "sessions"
learnings = "learnings"
memory = "memory"
sessions and learnings are required — a missing one raises. memory is
optional and defaults to "memory": it was added after the first stores existed,
and bumping version instead would have broken every one of them at once over a
directory they did not use yet. lh knowledge init writes all three.
Configuration resolves exactly one thing: where the store root is. How it is
laid out inside comes from knowledge.toml, and from nowhere else. The split is
deliberate — a root is environmental and differs per machine, while a layout is
global and must be identical everywhere. Two independent declarations of the
same layout is precisely how a directory named learnings ends up configured as
Learnings, which works on a case-insensitive filesystem and breaks on Linux.
Validation is loud on every failure: a missing file, an unknown version, an
absolute or escaping subdirectory name, or a missing field all raise rather than
fall back. A field read as "" would silently land files at the repository root.
Every file is plain markdown with YAML frontmatter. No database, no lock files, no hidden index. You can rsync -a the whole directory to backup, and rg it directly if you do not have QMD.
Session export in detail¶
Module: src/lazy_harness/knowledge/session_export.py. Called from the session-export built-in hook on every Stop event.
What gets parsed from the JSONL¶
Claude Code stores each session as a JSON-per-line file:
{"type":"permission-mode", ...} # interactive marker (or "last-prompt"), anywhere in the file
{"type":"system","cwd":"...", ...} # system message with metadata
{"type":"user","message":{"content":"..."}, ...}
{"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}, ...}
...
_parse_session_jsonl walks this and produces:
meta:{cwd, version, branch, timestamp}from the firstsystemrecord.messages: a list of{role, text, timestamp}dicts. Content extraction handles both string content ("hello") and structured content ([{"type":"text","text":"..."}]).is_interactive: set when apermission-modeorlast-promptrecord appears anywhere in the file, or, for a Codex transcript, a userresponse_item. Without it the export is skipped asnon-interactive(unless forced). The marker is not exclusive to interactive runs: Claude Code writeslast-promptin headlessclaude -pruns too, so this gate does not by itself keep them out; only themin_messagesthreshold below does. The compound loop's own evaluator sessions are therefore rejected earlier, before any other gate and even when forced:export_sessionreturnsevaluatorwhenis_evaluator_sessionfinds the evaluator prompt as the first user message.
The interactive + length filter¶
A session is only exported if it has at least min_messages (default 4) messages after the interactive filter. Below that threshold, the export function returns None and the hook logs a skip.
Classification rules¶
_classify(cwd, rules) returns (profile, session_type) based on the first rule whose pattern is a case-insensitive substring of cwd. Rules come from [[knowledge.classify_rules]] in config.toml; if the section is omitted, a built-in default list applies. See ADR-028.
Default rules (used when nothing is declared):
[[knowledge.classify_rules]]
pattern = "lazymind"
profile = "personal"
session_type = "vault"
[[knowledge.classify_rules]]
pattern = "obsidian"
profile = "personal"
session_type = "vault"
[[knowledge.classify_rules]]
pattern = "/repos/lazy/"
profile = "personal"
session_type = "personal"
[[knowledge.classify_rules]]
pattern = "/repos/flex/"
profile = "work"
session_type = "work"
Override entirely by declaring your own block — for example, a consultant juggling client work:
[[knowledge.classify_rules]]
pattern = "/srv/clients/acme/"
profile = "client"
session_type = "acme"
[[knowledge.classify_rules]]
pattern = "/opt/research/"
profile = "research"
session_type = "experiment"
Order matters: the first matching pattern wins. Anything that matches no rule is classified as ("other", "other"). Setting classify_rules = [] opts out completely. The profile value also drives the profile: frontmatter field, so context-inject and other downstream filters can discriminate without scanning bodies.
Project name extraction¶
_extract_project walks from cwd upward looking for the first .git directory and returns that directory's basename. If no git root is found, it falls back to basename(cwd). This is how a session in /Users/me/repos/lazy-harness/src/lazy_harness/hooks/ correctly identifies lazy-harness as the project.
There is a separate decoder, _decode_project_dir, that reverses Claude Code's directory-name mangling (/ replaced with -). Because hyphens appear inside real directory names (lazy-harness, lazy-claudecode), naive replace("-", "/") is wrong — the decoder tries candidate splits against the real filesystem and picks the one that exists.
The written file¶
Output path: <store>/sessions/YYYY-MM/YYYY-MM-DD-<short_id>.md (short_id = first 8 chars of session_id).
Frontmatter:
---
type: claude-session
session_id: 9a8b7c6d-1234-5678-90ab-cdef12345678
date: 2026-04-13 18:32
cwd: /Users/me/code/lazy-harness
project: lazy-harness
profile: personal
session_type: personal
branch: docs/restructure
claude_version: 1.2.3
messages: 47
---
Body: a sequence of ## User and ## Claude blocks with the extracted text, separated by blank lines. Tool-use blocks are intentionally not included — they would explode file size and are already captured by pre-compact for the sessions where file-touch history matters.
Idempotence¶
_existing_message_count reads the frontmatter of any pre-existing export at the same path. If the on-disk copy already contains at least as many messages as the current JSONL, the export is skipped. This makes the hook safe to re-run — for instance after a resume, or during a lh migrate replay.
Atomic writes¶
Every write goes through _atomic_write: write to <dir>/.filename.tmp, then os.replace to the target. A sync observer (iCloud, Dropbox) sees a single rename event rather than a window during which the file is partially written. Required whenever the store lives under a synced folder.
Learnings via compound loop¶
The compound-loop worker writes one markdown file per learning into the store's learnings subdirectory. The structure is identical in spirit to session exports: year-month subdirectories, dated filenames, YAML frontmatter. Learning filenames carry a trailing -<host> slug so that two machines distilling the same title on the same day produce two files rather than one conflict.
For the full flow, see how the memory compound loop works. The key points for the pipeline:
- Learnings are write-once. Existing files are not overwritten. This is intentional — a learning is a snapshot of a specific session's insight, and editing it later changes the historical record.
- Deduplication happens in the LLM prompt, not in the filesystem. The worker passes the titles of the last 50 learnings into the prompt with explicit "do not generate semantic duplicates" instructions.
- Learnings carry a
scopefield (universal | backend | infra | consulting) that lets future queries slice by applicability. - Nothing prunes or merges near-duplicates after the fact. The tree is append-only by construction and reviewing it is a manual read — the framework ships no learnings-review job.
Transport: the store is a git repository¶
The store is checked out on every machine that writes to it, and a scheduler job
runs lh knowledge push on each of them. One cycle is:
- Take an exclusive
flock. A held lock means another cycle is running, so this one exits 0 silently. - Validate
knowledge.toml. An unusable marker stops here, before git is touched at all. - Make sure
.gitattributescarriesmerge=unionfor*.jsonlandclaude-md.proposal.md, adding only the missing lines. A store with no remote skips this step. add -A, then commit with a subject counting what changed and naming the host. Nothing to commit and nothing unpushed is not an early exit: the cycle still fetches.fetch, thenmerge --no-editof the upstream. A clone that is only behind fast-forwards and reportsupdated; a diverged one gets a single merge commit, so a long divergence is never replayed commit by commit.push. On failure the commits stay local and the next cycle retries.
No producer ever calls this. The session-export hook and the compound-loop
worker only write files, which is what makes the transport failure-tolerant: with
no network, no credentials, or a dead remote, the writes still land on disk and
the next cycle picks them up. No session ever blocks on a network syscall.
Most writes create a new file under a unique key, and the host suffix on learning
filenames keeps those keys unique, so two clones union without conflict. Three
kinds of file under memory/<host>/<org>/<repo>/ are modified instead, and each
has a rule:
| File | Written by | Merge rule |
|---|---|---|
*.jsonl (decisions, failures, grades) |
appended | union: both sides' lines are kept |
claude-md.proposal.md |
appended | union: both sides' lines are kept |
handoff.md |
overwritten whole | newest wins, by written_at |
For handoff.md the cycle reads the timestamp from either format found in the
wild, the written_at: frontmatter key or the - Written at: body line, and
compares them as timezone-aware datetimes, so differing offsets are handled. A
conflict on any other path, or a handoff whose timestamp is missing, unparseable
or without an offset on either side, aborts the whole merge (merge --abort) and
exits non-zero; nothing is resolved blindly.
| Failure | Consequence | Data lost |
|---|---|---|
| No network at push | local commits accumulate | no |
| No network at fetch | commits stay local, next cycle retries | no |
Conflict outside handoff.md, or a handoff with no usable timestamp |
merge aborted, paths logged, stays pending | no |
| Lock held | cycle skipped | no |
| Invalid marker | loud failure, nothing written | no |
QMD integration¶
QMD is an external semantic search tool the framework integrates with but does not require. Module: src/lazy_harness/knowledge/qmd.py.
How the framework calls QMD¶
One entry point: run_qmd(action, collection, timeout) — a thin wrapper around subprocess.run(["qmd", action, "--collection", collection]). Three convenience functions:
sync(collection)— callsqmd update, which re-parses markdown files and updates indices.embed(collection)— callsqmd embed, which computes embeddings for newly synced files.status()— callsqmd status, which reports index health.
The session-export hook triggers qmd update automatically at the end of every exported session. Embedding is left to scheduled jobs — it is more expensive and does not need to happen synchronously.
QMD availability and graceful degradation¶
Every call site starts with shutil.which("qmd"). If QMD is not installed, the framework silently skips the indexing step. All other parts of the pipeline still work: sessions are still exported to markdown, compound loop still writes learnings, context-inject still reads files directly. What you lose is cross-session semantic recall — qmd query "circular imports python" is the feature that is unavailable.
QMD can be installed later without re-running any framework command. The next session-export invocation will pick up the binary and start triggering qmd update.
Collection configuration¶
QMD treats each indexed directory as a named collection. The framework's convention is that each store subdirectory is its own collection — raw transcripts and distilled learnings are corpora of very different value, and a single collection makes the distilled one unreachable behind the raw one — and the collection name is configured externally in QMD itself (not in lazy-harness). This keeps the framework unopinionated about QMD's config model — we call qmd the same way you would call it by hand.
Querying the knowledge tree¶
With QMD:
qmd query "when did I last deal with symlinks on macOS"
# returns matching session/learning files with scores
Without QMD:
rg -l "symlink" ~/Documents/lazy-harness-knowledge/
# or
find ~/Documents/lazy-harness-knowledge -name '*.md' | xargs rg 'symlink'
Both work. One is faster and semantic; the other is always available and a good fallback.
Inside a session, Claude Code itself can also read these files as regular markdown — which means an agent with filesystem access can answer "what did we learn about X last quarter" by reading the learnings directory directly, without needing QMD as a tool.
Backup and portability¶
Because the entire knowledge pipeline outputs to a single directory of markdown files:
# Backup
rsync -a ~/Documents/lazy-harness-knowledge /backup/location/
# Restore
rsync -a /backup/location/lazy-harness-knowledge ~/Documents/
# Git-version it
cd ~/Documents/lazy-harness-knowledge && git init
Moving to a new machine is "point [knowledge].root in config.toml (or $LAZY_KNOWLEDGE_ROOT) at the clone and run qmd update". Nothing inside the framework holds state that can diverge from the directory contents — the layout comes from the store's own knowledge.toml, and each project's memory is keyed by its git remote rather than by where the checkout sits on this machine.
Other consumers of the same pipeline¶
QMD is the headline consumer of the knowledge tree, but it is not the only one. Two more pieces plug into the same on-disk artifacts and are worth understanding alongside the main pipeline.
Engram persistence loop — JSONL mirrored into a per-project store¶
Module: src/lazy_harness/knowledge/engram_persist.py. Wired as a Stop hook (engram-persist) that runs after compound-loop writes its new entries. Design rationale: ADR-029.
While the JSONL pair (decisions.jsonl / failures.jsonl) is the file-of-record, Engram is the agent's MCP-queryable view of the same data. The mirror keeps both views in sync without dual-writing from compound-loop itself — compound-loop only writes to JSONL, and engram-persist reads what is new and ships it to engram save.
Determinism comes from a per-file byte cursor, one per project per machine. With memory in the knowledge store it lives in <lh data dir>/engram-cursors/<memory dir relative to the store>/engram_cursor.json, shared by every profile and never synced; memory outside the store keeps a per-profile cursor under <agent dir>/engram-cursors/:
{
"version": 1,
"decisions_offset": 18234,
"failures_offset": 5120,
"updated_at": "2026-04-13T18:33:01Z"
}
Each run, for each kind:
- Open the JSONL, seek to the stored offset.
- Read whole lines until EOF. Partial lines are deferred (the writer might still be flushing the entry).
- Decode each line as JSON; malformed lines advance the cursor and are counted as
skipped_malformed— they are not retried. - Call
engram save --type <kind> --project <project_key> --scope project -- <title> <json>— flags first, so a summary starting with--is not read as a flag. The project key is the parent ofgit rev-parse --path-format=absolute --git-common-dir, taken by name — so a linked worktree collapses onto the repository that owns it rather than registering as its own project. - On success, advance the cursor (atomic tempfile +
os.replace). At most 25 saves are attempted per run (MAX_SAVES_PER_RUN); the rest drains on later runs. - On failure, leave the cursor untouched and stop processing this kind. The next run picks up from the same offset → at-least-once delivery.
- After five failed exits for one entry (
MAX_ATTEMPTS_PER_ENTRY; timeouts do not count), move it toengram_quarantine.jsonlbeside the cursor and advance, so it cannot block the entries after it. Ordering holds for every entry but a quarantined one.
This produces four things on disk besides the Engram DB itself:
engram_cursor.json— offsets per kind and failed attempts at a stuck entry, atomically updated, in the location above.engram_quarantine.jsonl— beside the cursor, the entries that ran out of attempts, with their raw line and last error.<agent runtime dir>/logs/engram_persist.log— append-only error log, one line per subprocess failure (both output streams), quarantine or missing binary.<agent runtime dir>/logs/engram_persist_metrics.jsonl— one record per run (run summary) plus one per slowengram save(≥ 500 ms). Run records include whether the save cap was reached and whether the cursor file advanced.lh doctorreads these with age, failure rate, cursor lag, and quarantined entries; a capped run with persisted progress and no failed save reportscatching upinstead of failing for large lag.
If engram is not on PATH, the loop logs a no-op and exits cleanly. Like every other external integration in the framework, this layer is opt-in: install engram, declare [memory.engram].enabled = true, the rest of the pipeline keeps working unchanged.
Structural layer — Graphify¶
Module: src/lazy_harness/knowledge/graphify.py. Pinned to a specific Graphify version (see ADR-023) so that an upgrade is an explicit decision rather than a transparent behaviour change.
Graphify is a tree-sitter–based code-structure indexer covering 25 languages. Where QMD answers "where did we discuss X across all my notes?", Graphify answers "what calls this function?", "explain this module", or "show me the dependency neighbourhood of this symbol". Its output is a JSON graph plus an interactive HTML report under graphify-out/ inside each repo, not in the knowledge store.
The harness's job is integration, not re-implementation:
is_graphify_available()probesshutil.which("graphify"). If absent, every other piece of the framework keeps working — the structural layer is simply missing.check_version()parsesgraphify --versionand compares againstPINNED_VERSION.lh doctorsurfaces a row when the installed version drifts from the pin.run_graphify(action, target, timeout)is a thin wrapper aroundsubprocess.run(["graphify", action, target]), used by the/graphifyskill in interactive sessions. The framework never invokes Graphify automatically — building the graph is up to the user (or a scheduled job, or a post-commit hook).
Configuration lives under [knowledge.structure] in config.toml:
[knowledge.structure]
engine = "graphify"
enabled = true
version = "0.9.67"
enabled = true is what lh deploy reads to wire the Graphify MCP entry into each profile's agent config, provided the graphify-mcp binary is present. Rebuilds are demand-driven: run graphify update <path> to refresh a graph from the AST without an LLM call, or graphify check-update <path> to test whether one is due.
Because graphify-out/ is checked into the repo by convention, teammates and future sessions reuse the index without rebuilding. Adding it to .gitignore is a deliberate (if rare) choice for repos where build cost dominates over reuse.