Module: ClaudeMemory::Audit::Checks
- Defined in:
- lib/claude_memory/audit/checks.rb
Overview
Individual audit checks. Each method takes a Store::StoreManager and returns an Array<Finding>. Checks must be read-only — write operations belong in dedicated commands the user opts into.
Adding a new check:
1. Define a method here with an explicit C### id assignment.
2. Append the method name to Runner::CHECK_METHODS.
3. Document it in docs/audit_runbook.md.
Constant Summary collapse
- SINGLE_CARDINALITY_PREDICATES =
C002 — Single-cardinality predicates with > 1 active fact.
%w[uses_database deployment_platform auth_method].freeze
- CHURN_THRESHOLD =
C010 — Recurring single-cardinality churn (history shows the same predicate has accumulated many superseded/disputed facts — sign of a persistent contamination source).
5
Class Method Summary collapse
-
.auto_memory_unimported(manager) ⇒ Object
C009 — Auto-memory drift (markdown files newer than project DB facts).
-
.bare_conclusion_rate(manager) ⇒ Object
C007 — Bare-conclusion decisions/conventions (no reason clause).
-
.distillation_backlog(manager) ⇒ Object
C003 — Distillation backlog (warn ≥ 25, error ≥ 100).
-
.duplicate_global_conventions(manager) ⇒ Object
C006 — Duplicate global convention candidates (near-identical text).
- .normalize_convention(text) ⇒ Object
-
.open_conflicts(manager) ⇒ Object
C001 — Open conflicts in either DB.
-
.project_starvation(manager) ⇒ Object
C008 — Project DB starvation (< 5 active facts may indicate broken ingest).
-
.shortcut_convention_scope(manager) ⇒ Object
C005 — memory.conventions returns no project facts despite project conventions existing.
-
.shortcut_decision_leak(manager) ⇒ Object
C004 — memory.decisions leaking non-decision predicates.
- .single_cardinality_churn(manager) ⇒ Object
- .single_cardinality_multiplicity(manager) ⇒ Object
Class Method Details
.auto_memory_unimported(manager) ⇒ Object
C009 — Auto-memory drift (markdown files newer than project DB facts).
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/claude_memory/audit/checks.rb', line 176 def auto_memory_unimported(manager) config = Configuration.new dir = Hook::AutoMemoryMirror.default_dir(config.project_dir, config.claude_config_dir) return [] unless Dir.exist?(dir) md_files = Dir.glob(File.join(dir, "*.md")).reject { |f| File.basename(f) == "MEMORY.md" } return [] if md_files.empty? store = manager.store_if_exists("project") return [] unless store # Look for auto_memory_import content items as evidence of prior # import. Count files that would be new on the next import. imported_count = store.content_items.where(source: "auto_memory_import").count net_new = md_files.size - imported_count return [] if net_new <= 0 [Finding.new( id: "C009", severity: :info, title: "#{net_new} auto-memory file(s) not yet imported", detail: "~/.claude/projects/<slug>/memory/*.md files contain durable knowledge that isn't reachable via memory.recall until imported. AutoMemoryMirror only surfaces them transiently at SessionStart.", suggestion: "Preview: claude-memory import-auto-memory --dry-run. Import: claude-memory import-auto-memory.", fact_ids: [] )] end |
.bare_conclusion_rate(manager) ⇒ Object
C007 — Bare-conclusion decisions/conventions (no reason clause).
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/claude_memory/audit/checks.rb', line 137 def (manager) store = manager.store_if_exists("project") return [] unless store detector = Distill::BareConclusionDetector.new rows = store.facts.where(status: "active", predicate: %w[decision convention]).select(:id, :predicate, :object_literal).all = rows.select { |r| detector.(predicate: r[:predicate], object_literal: r[:object_literal]) } return [] if .empty? ratio = .size.to_f / rows.size return [] if ratio < 0.3 [Finding.new( id: "C007", severity: :info, title: "#{(ratio * 100).round}% of decisions/conventions lack reason clauses (#{.size}/#{rows.size})", detail: "Facts without 'because/so that/to avoid/...' lose their justification once context fades. Bare conclusions are dead weight when the team grows or you revisit a year later.", suggestion: "Inspect with: claude-memory explain <fact_id>. Reject low-value bare facts or rewrite with reason clauses via memory.store_extraction.", fact_ids: .map { |r| r[:id] } )] end |
.distillation_backlog(manager) ⇒ Object
C003 — Distillation backlog (warn ≥ 25, error ≥ 100).
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/claude_memory/audit/checks.rb', line 57 def distillation_backlog(manager) store = manager.store_if_exists("project") return [] unless store distilled_ids = store.ingestion_metrics.select(:content_item_id).distinct pending = store.content_items.exclude(id: distilled_ids).count return [] if pending < 25 severity = (pending >= 100) ? :error : :warn [Finding.new( id: "C003", severity: severity, title: "#{pending} content items not yet deeply distilled", detail: "Backlog grows when SessionStart distillation prompts aren't acknowledged with memory.mark_distilled. A large backlog means the same text gets re-extracted across sessions, increasing hallucination rate.", suggestion: "Triage with /distill-transcripts (interactive) OR mark all distilled if you accept the backlog is noise: claude-memory sweep --mark-all-distilled", fact_ids: [] )] end |
.duplicate_global_conventions(manager) ⇒ Object
C006 — Duplicate global convention candidates (near-identical text).
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/claude_memory/audit/checks.rb', line 113 def duplicate_global_conventions(manager) store = manager.store_if_exists("global") return [] unless store rows = store.facts.where(status: "active", predicate: "convention").select(:id, :object_literal).all return [] if rows.size < 2 # Group by normalized object text (lowercased, stripped of leading # "uses"/"prefers"/punctuation). Pairs with the same normalized # key are likely near-duplicates. groups = rows.group_by { |r| normalize_convention(r[:object_literal]) } dupe_groups = groups.select { |_, list| list.size > 1 } return [] if dupe_groups.empty? [Finding.new( id: "C006", severity: :info, title: "#{dupe_groups.size} near-duplicate global convention group(s)", detail: "Multiple global conventions normalize to the same phrasing. Pick the cleanest and reject the rest to keep memory.conventions output tight.", suggestion: "Review with: claude-memory recall <concept> --scope=global. Reject duplicates: claude-memory reject <fact_id>", fact_ids: dupe_groups.values.flatten.map { |r| r[:id] } )] end |
.normalize_convention(text) ⇒ Object
229 230 231 232 233 234 235 236 |
# File 'lib/claude_memory/audit/checks.rb', line 229 def normalize_convention(text) text.to_s .downcase .gsub(/\b(?:uses|prefers|always|never)\b/, "") .gsub(/[[:punct:]]/, "") .gsub(/\s+/, " ") .strip end |
.open_conflicts(manager) ⇒ Object
C001 — Open conflicts in either DB.
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
# File 'lib/claude_memory/audit/checks.rb', line 17 def open_conflicts(manager) findings = [] {project: manager.store_if_exists("project"), global: manager.store_if_exists("global")}.each do |scope, store| next unless store conflicts = store.open_conflicts next if conflicts.empty? findings << Finding.new( id: "C001", severity: :error, title: "#{conflicts.size} open conflict(s) in #{scope} DB", detail: "Open conflicts indicate unresolved single-cardinality disputes. Each will keep re-firing until the losing fact is rejected.", suggestion: "claude-memory conflicts && claude-memory reject <fact_id>", fact_ids: conflicts.flat_map { |c| [c[:fact_a_id], c[:fact_b_id]] }.uniq ) end findings end |
.project_starvation(manager) ⇒ Object
C008 — Project DB starvation (< 5 active facts may indicate broken ingest).
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/claude_memory/audit/checks.rb', line 159 def project_starvation(manager) store = manager.store_if_exists("project") return [] unless store count = store.facts.where(status: "active").count return [] if count >= 5 [Finding.new( id: "C008", severity: :warn, title: "Only #{count} active project fact(s)", detail: "A nearly-empty project DB suggests either a fresh install (ignore) OR a broken ingest pipeline / overzealous rejection. Verify hooks are firing: claude-memory doctor.", suggestion: "claude-memory doctor; claude-memory stats; check .claude/settings.json hook configuration.", fact_ids: [] )] end |
.shortcut_convention_scope(manager) ⇒ Object
C005 — memory.conventions returns no project facts despite project conventions existing.
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/claude_memory/audit/checks.rb', line 92 def shortcut_convention_scope(manager) project_store = manager.store_if_exists("project") return [] unless project_store project_count = project_store.facts.where(status: "active", predicate: "convention").count return [] if project_count.zero? results = Shortcuts.conventions(manager, limit: 50) project_returned = results.count { |r| r[:source] == "project" } return [] if project_returned > 0 [Finding.new( id: "C005", severity: :warn, title: "memory.conventions returned 0 project facts despite #{project_count} project conventions existing", detail: "Pre-2026-05-21 audit, memory.conventions was hardcoded to scope=global. If you're seeing 0 project facts in a project with conventions, the shortcut has regressed.", suggestion: "Check Shortcuts.collect_facts in lib/claude_memory/shortcuts.rb. Re-run `bundle exec rspec spec/claude_memory/shortcuts_spec.rb`.", fact_ids: [] )] end |
.shortcut_decision_leak(manager) ⇒ Object
C004 — memory.decisions leaking non-decision predicates.
76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/claude_memory/audit/checks.rb', line 76 def shortcut_decision_leak(manager) results = Shortcuts.decisions(manager, limit: 50) leaked = results.map { |r| r[:fact][:predicate] }.uniq - ["decision"] return [] if leaked.empty? [Finding.new( id: "C004", severity: :error, title: "memory.decisions returns non-decision predicates: #{leaked.inspect}", detail: "memory.decisions should return only `decision`-predicate facts. Predicate leakage suggests the shortcut implementation has regressed back to text-search filtering (pre-2026-05-21 audit).", suggestion: "Inspect lib/claude_memory/shortcuts.rb — SHORTCUTS[:decisions][:predicates] should equal ['decision']. Run `bundle exec rspec spec/claude_memory/shortcuts_spec.rb`.", fact_ids: results.select { |r| leaked.include?(r[:fact][:predicate]) }.map { |r| r[:fact][:id] } )] end |
.single_cardinality_churn(manager) ⇒ Object
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/claude_memory/audit/checks.rb', line 208 def single_cardinality_churn(manager) store = manager.store_if_exists("project") return [] unless store SINGLE_CARDINALITY_PREDICATES.flat_map do |predicate| non_active = store.facts .where(predicate: predicate, status: %w[superseded disputed rejected]) .count next [] if non_active < CHURN_THRESHOLD [Finding.new( id: "C010", severity: :warn, title: "predicate=#{predicate} shows churn: #{non_active} historical non-active facts", detail: "Repeated supersession/dispute on a single-cardinality predicate usually means a contamination source (e.g., example text in CLAUDE.md or docs) keeps re-introducing the same hallucination.", suggestion: "Find the contamination source: claude-memory recall <bad_value> --scope=project. Wrap the trigger text in <no-memory> tags. See docs/audit_runbook.md.", fact_ids: [] )] end end |
.single_cardinality_multiplicity(manager) ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# File 'lib/claude_memory/audit/checks.rb', line 38 def single_cardinality_multiplicity(manager) store = manager.store_if_exists("project") return [] unless store SINGLE_CARDINALITY_PREDICATES.flat_map do |predicate| rows = store.facts.where(status: "active", predicate: predicate).all next [] if rows.size <= 1 [Finding.new( id: "C002", severity: :error, title: "predicate=#{predicate} has #{rows.size} active facts (single-cardinality)", detail: "Single-cardinality predicates must have at most one active value. Multiple actives mean resolver dropped a supersession or distillation produced contradictory claims.", suggestion: "Inspect with: claude-memory explain <fact_id>. Reject the wrong ones: claude-memory reject <fact_id>", fact_ids: rows.map { |r| r[:id] } )] end end |