7 The ambient loop
Nothing in the earlier chapters required the user to type a claimgraph verb, but it all assumed someone writes facts. The ambient loop removes that assumption. The harness’s own auto-memory (the notes Claude Code and Codex maintain about a project) becomes an ingestion tier, and the graph compiles its current view back into the file the harness injects at session start. Capture is delegated in, injection is delegated out, and the graph consolidates in the middle. A SessionEnd hook runs the loop automatically, at the end of every session.
This chapter executes the deterministic halves: the compile side in full, and the pure machinery of the ingest side (delta detection, the echo guard, the inference-grade clamp). The LLM half of ingestion is a pluggable subprocess and stays out of a book build on purpose.
(ns ambient
(:require [babashka.fs :as fs]
[claimgraph.context :as context]
[claimgraph.core :as core]
[claimgraph.harness :as harness]
[claimgraph.ingest.notes :as notes]
[claimgraph.store.memory :as mem]))7.1 Harness adapters
Each harness is described by an adapter: where its notes live, which files count as notes, and which file it injects into every session. Claude Code injects MEMORY.md; Codex injects memory_summary.md:
(-> (harness/resolve-harness :claude-code)
(select-keys [:id :note-glob :inject-file])){:id :claude-code, :note-glob "**.md", :inject-file "MEMORY.md"}(-> (harness/resolve-harness :codex)
(select-keys [:id :note-glob :inject-file])){:id :codex, :note-glob "**.{md,txt}", :inject-file "memory_summary.md"}Two harnesses, one graph: notes from both merge through the same entity resolution and conflict machinery, which makes claimgraph a cross-harness consolidator. Nothing else in the 2026 field survey does this.
7.2 A graph worth compiling
The compiled view leads with what a session most needs to know: standing decisions, open conflicts, what changed recently, then the top current facts. Build a store with one of each:
(def store (doto (mem/create) (core/seed!)))(core/assert-fact store {:subject "api-layer" :predicate :core/decided-against
:object "GraphQL" :object-kind :literal
:epistemic :commitment :source-type :decision-record
:t-valid #inst "2026-05-01"}){:status :created,
:fact
{:object-ref nil,
:source-type :decision-record,
:object-lit "GraphQL",
:scope "project",
:epistemic :commitment,
:recorded-at #inst "2026-08-08T16:41:19.183-00:00",
:id "f-afaf33a2-a517-4d5c-9120-1cd4a6a54646",
:t-valid #inst "2026-05-01T00:00:00.000-00:00",
:object-kind :literal,
:t-invalid nil,
:episode nil,
:confidence 0.8,
:predicate :core/decided-against,
:subject
{:id "e-18e37db1-97d3-4688-bec0-e95acbc4473b",
:name "api-layer",
:type nil,
:scope "project",
:aliases []},
:last-reinforced-at #inst "2026-08-08T16:41:19.183-00:00"}}A supersession (the “what changed” briefing): the version moved on July 10th.
(core/assert-fact store {:subject "AuthService" :predicate :core/has-version
:object "1.0.0" :object-kind :literal :source-type :code
:t-valid #inst "2026-06-01"}){:status :created,
:fact
{:object-ref nil,
:source-type :code,
:object-lit "1.0.0",
:scope "project",
:epistemic :observation,
:recorded-at #inst "2026-08-08T16:41:19.184-00:00",
:id "f-26edc19d-3cf5-40a6-a648-5af180669e13",
:t-valid #inst "2026-06-01T00:00:00.000-00:00",
:object-kind :literal,
:t-invalid nil,
:episode nil,
:confidence 0.8,
:predicate :core/has-version,
:subject
{:id "e-4dad45c6-fb04-402f-85b6-1e6fb490b703",
:name "AuthService",
:type nil,
:scope "project",
:aliases []},
:last-reinforced-at #inst "2026-08-08T16:41:19.184-00:00"}}(core/assert-fact store {:subject "AuthService" :predicate :core/has-version
:object "2.0.0" :object-kind :literal :source-type :code
:t-valid #inst "2026-07-10"}){:status :superseded,
:fact
{:object-ref nil,
:source-type :code,
:object-lit "2.0.0",
:scope "project",
:epistemic :observation,
:recorded-at #inst "2026-08-08T16:41:19.185-00:00",
:id "f-c7a5edc3-c176-4ee9-9288-95715412eb19",
:t-valid #inst "2026-07-10T00:00:00.000-00:00",
:object-kind :literal,
:t-invalid nil,
:episode nil,
:confidence 0.8,
:predicate :core/has-version,
:subject
{:id "e-4dad45c6-fb04-402f-85b6-1e6fb490b703",
:name "AuthService",
:type nil,
:scope "project",
:aliases []},
:last-reinforced-at #inst "2026-08-08T16:41:19.185-00:00"},
:superseded ["f-26edc19d-3cf5-40a6-a648-5af180669e13"]}An open conflict (a session claim colliding with the commitment):
(core/assert-fact store {:subject "api-layer" :predicate :core/prefers
:object "GraphQL" :object-kind :literal
:source-type :session-log
:t-valid #inst "2026-07-15"}){:status :flagged,
:fact
{:object-ref nil,
:conflicts ["f-afaf33a2-a517-4d5c-9120-1cd4a6a54646"],
:source-type :session-log,
:object-lit "GraphQL",
:scope "project",
:epistemic :preference,
:recorded-at #inst "2026-08-08T16:41:19.186-00:00",
:id "f-205e670c-c42b-44fb-8a94-d61a0ec6e708",
:t-valid #inst "2026-07-15T00:00:00.000-00:00",
:object-kind :literal,
:t-invalid nil,
:episode nil,
:confidence 0.7,
:predicate :core/prefers,
:subject
{:id "e-18e37db1-97d3-4688-bec0-e95acbc4473b",
:name "api-layer",
:type nil,
:scope "project",
:aliases []},
:last-reinforced-at #inst "2026-08-08T16:41:19.186-00:00"},
:candidates
[{:object-ref nil,
:source-type :decision-record,
:object-lit "GraphQL",
:scope "project",
:epistemic :commitment,
:recorded-at #inst "2026-08-08T16:41:19.183-00:00",
:id "f-afaf33a2-a517-4d5c-9120-1cd4a6a54646",
:t-valid #inst "2026-05-01T00:00:00.000-00:00",
:object-kind :literal,
:t-invalid nil,
:episode nil,
:confidence 0.8,
:predicate :core/decided-against,
:subject
{:id "e-18e37db1-97d3-4688-bec0-e95acbc4473b",
:name "api-layer",
:type nil,
:scope "project",
:aliases []},
:last-reinforced-at #inst "2026-08-08T16:41:19.183-00:00"}]}And ordinary current facts, one of them code-derived:
(core/assert-fact store {:subject "billing" :predicate :core/prefers
:object "idempotency keys on retries"
:object-kind :literal :source-type :user-assertion
:t-valid #inst "2026-06-20"}){:status :created,
:fact
{:object-ref nil,
:source-type :user-assertion,
:object-lit "idempotency keys on retries",
:scope "project",
:epistemic :preference,
:recorded-at #inst "2026-08-08T16:41:19.187-00:00",
:id "f-291b65b8-1483-4d27-a460-05bd1fd83a03",
:t-valid #inst "2026-06-20T00:00:00.000-00:00",
:object-kind :literal,
:t-invalid nil,
:episode nil,
:confidence 0.8,
:predicate :core/prefers,
:subject
{:id "e-37cfaf7c-7de7-4475-9293-c2e324ce6936",
:name "billing",
:type nil,
:scope "project",
:aliases []},
:last-reinforced-at #inst "2026-08-08T16:41:19.187-00:00"}}(core/assert-fact store {:subject "billing" :predicate :core/depends-on
:object "stripe-client" :source-type :code
:confidence 0.95
:t-valid #inst "2026-06-01"}){:status :created,
:fact
{:object-ref
{:id "e-a59e1989-90e9-4eeb-ac7b-f4c6dfd2b0d7",
:name "stripe-client",
:type nil,
:scope "project",
:aliases []},
:source-type :code,
:object-lit nil,
:scope "project",
:epistemic :observation,
:recorded-at #inst "2026-08-08T16:41:19.188-00:00",
:id "f-00acf918-25a5-4205-a47e-6448635986b6",
:t-valid #inst "2026-06-01T00:00:00.000-00:00",
:object-kind :entity,
:t-invalid nil,
:episode nil,
:confidence 0.95,
:predicate :core/depends-on,
:subject
{:id "e-37cfaf7c-7de7-4475-9293-c2e324ce6936",
:name "billing",
:type nil,
:scope "project",
:aliases []},
:last-reinforced-at #inst "2026-08-08T16:41:19.188-00:00"}}7.3 compile-context
The compile is deterministic, budgeted to the harness’s injection window (25 KB default), and idempotent. Point it at a directory standing in for ~/.claude/projects/<project>/memory/:
(def notes-dir (str (fs/create-temp-dir {:prefix "claimgraph-book"}) "/memory"))(def result
(context/compile! store {:harness :claude-code
:dir notes-dir
:now #inst "2026-07-18"}))(-> result
(select-keys [:harness :file :bytes :sections])
(update :file fs/file-name)){:harness "claude-code",
:file "MEMORY.md",
:bytes 766,
:sections {:commitments 1, :conflicts 1, :supersessions 1, :facts 1}}The managed section it wrote, verbatim:
(println (slurp (:file result)))<!-- claimgraph:managed:begin -->
## Project memory — compiled by claimgraph (as of 2026-07-18)
The knowledge graph's current view. Regenerated on each compile; edits
here are discarded and never re-ingested. History, provenance, and
time-travel: `bin/claim help`.
### Standing decisions (do not relitigate)
- api-layer decided-against "GraphQL" (since 2026-05-01)
### Open conflicts (awaiting review — `claim conflicts`)
- 2 claims in contention across 2 predicates — api-layer decided-against (1: "GraphQL"); api-layer prefers (1: "GraphQL")
### Changed recently
- AuthService has-version: "1.0.0" → "2.0.0" (2026-07-10)
### Current facts (by effective confidence)
- billing prefers "idempotency keys on retries" (0.80)
<!-- claimgraph:managed:end -->
nilRead it the way a fresh session would. Standing decisions first, the open conflict flagged as unresolved, the supersession dated, and the top facts last. Two exclusions are deliberate: code-derived facts (the code can speak for itself; the view carries what the code cannot say) and any fact involved in an open conflict (disputed claims do not get presented as current truth; that exclusion is what turned the benchmark’s poisoning attack from a wrong confident answer into an honest abstention).
When the content outgrows the budget, lines drop from the bottom (facts before conflicts before commitments) and the view always announces the truncation, so a reader knows to query the graph for the rest.
7.4 The ingest side, and the echo guard
ingest-notes scans the notes directory, hashes each file’s ingestible content, and extracts only files whose hash has no ingestion episode yet. The store’s episode log is the delta state; there is no separate bookkeeping file. The crucial move is what gets hashed: the managed section is stripped first.
(def note-file (str (fs/path notes-dir "MEMORY.md")))Simulate the harness adding its own note next to the managed section:
(spit note-file
(str (slurp note-file)
"\n## Debugging\n- payout cron fails on DST transitions\n"))nil(def hash-with-note
(notes/content-hash (harness/strip-managed-section (slurp note-file))))Now recompile (the graph moved on; say the conflict got resolved). The file’s bytes change, but its ingestible content does not:
(core/invalidate store
{:fact-id (->> (core/get-facts store {:entity "api-layer"
:predicate :core/prefers})
:facts first :id)
:reason "the ADR stands"}){:status :invalidated,
:fact-id "f-205e670c-c42b-44fb-8a94-d61a0ec6e708",
:at #inst "2026-08-08T16:41:19.198-00:00"}(-> (context/compile! store {:harness :claude-code
:dir notes-dir
:now #inst "2026-07-19"})
(select-keys [:bytes :sections])){:bytes 582,
:sections {:commitments 1, :conflicts 0, :supersessions 1, :facts 1}}(= hash-with-note
(notes/content-hash (harness/strip-managed-section (slurp note-file))))trueThat equality is the echo guard: compile, ingest, compile is a fixed point, and the graph never re-consumes its own compiled view. Only the harness’s own notes are upstream.
7.5 Inference grade, enforced
Whatever the extractor returns is clamped before it touches the write path. Notes flatten who-said-what, so note-derived facts are always second-class evidence: source-type agent-note, confidence capped at 0.65, and never a commitment, even when the extractor claims one:
(notes/prepare-note-facts
[{:subject "api-layer" :predicate "decided-against" :object "REST"
:class "commitment" :confidence 0.95}
{:subject "billing" :predicate "prefers" :object "small batches"
:class "preference" :confidence 0.5}
{:subject "" :predicate "prefers" :object "incomplete"}]){:facts
[{:subject "api-layer",
:predicate "decided-against",
:object "REST",
:confidence 0.65,
:epistemic :observation,
:source-type :agent-note}
{:subject "billing",
:predicate "prefers",
:object "small batches",
:confidence 0.5,
:epistemic :preference,
:source-type :agent-note}],
:demoted 1,
:rejected [{:subject "", :predicate "prefers", :object "incomplete"}]}The reported decision was demoted to an observation at 0.65, the preference kept its class, and the incomplete triple was rejected. A genuine decision still has exactly one road into the graph: a human (or the skill acting on the human’s words) asserting --class commitment.
7.6 The hook that runs it
bin/claim hooks install # SessionEnd: spawn the capture pass
# (ingest-code-if-changed, compile-context),
# which spawns a detached `claim curate`
bin/claim hooks install --coach # also: a UserPromptSubmit gate that
# interrupts only when the graph holds a
# standing decision, failure mode, or open
# conflict touching the prompt
bin/claim hooks run # the capture pass, by hand
bin/claim curate # the curation run, by handThe loop splits along the determinism boundary. Capture — the hook’s own pass — is deterministic end to end and costs seconds: the code stage, then the recompile, logging to <db>.capture.log. Every model call belongs to the detached curator it spawns: claim curate runs notes ingest, then consolidation (judge, summaries, sweep, enrichment), then a final recompile so the next session’s view carries what curation learned. One model-call budget spans the run, every call lands a durable outcome (so runs converge toward a free no-op), the curation lease makes it a singleton, and it logs to <db>.curate.log. Quitting a session therefore costs a process spawn; nothing waits on a model.
The code stage keeps the mechanical tier fresh with the same zero-effort economics as the rest of the loop: it is delta-gated on <git-sha>+<dirty-digest> against the last code episode’s ref, so it costs milliseconds when nothing changed and reconciles when anything did — including teammates’ pulled changes, which no skill nudge would ever catch (the code-ingest setting opts it out for projects with expensive analyzers). Stages report independently: an analyzer failure never blocks the deterministic recompile. The coach is the push-side complement to the skill’s pull-side judgment, and it stays silent unless the gate fires; always-on injection is the pattern the AGENTS.md study measured into the ground, and the mutating-step evidence (SABER) says the moment worth interrupting is the one right before a write.
The next chapter turns this machinery around: the same conflict engine, pointed at the memory pile a repo already has — before claimgraph is installed at all.
source: book/chapters/ambient.clj