From 6c50d79c1609a5dd63dbf71c9de789de289c6110 Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:20:18 -0400 Subject: [PATCH 1/5] =?UTF-8?q?Add=20two-stage=20fact=20expiry=20lifecycle?= =?UTF-8?q?=20(active=E2=86=92expiring=E2=86=92expired)=20per=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 021: reaffirmed_at + expiring_since columns on facts - Maintenance#mark_expiring_facts: stale (created + last recall + reaffirmed all past stale_fact_threshold_days, default 180) active facts → expiring - Maintenance#expire_unratified_facts: expiring past ratify_window_days (default 30) without ratification → expired (never deleted) - Wired into Sweeper budget loop; specs for both stages Co-Authored-By: Claude Fable 5 --- db/migrations/021_add_fact_lifecycle.rb | 31 +++++++++++ lib/claude_memory/store/schema_manager.rb | 2 +- lib/claude_memory/sweep/maintenance.rb | 34 +++++++++++- lib/claude_memory/sweep/sweeper.rb | 4 ++ spec/claude_memory/sweep/maintenance_spec.rb | 57 +++++++++++++++++++- 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 db/migrations/021_add_fact_lifecycle.rb diff --git a/db/migrations/021_add_fact_lifecycle.rb b/db/migrations/021_add_fact_lifecycle.rb new file mode 100644 index 0000000..b0528cb --- /dev/null +++ b/db/migrations/021_add_fact_lifecycle.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Migration v21: two-stage fact expiry lifecycle (#14). +# +# - reaffirmed_at: set ONLY by explicit ratification (CLI/MCP ratify +# surface) — never by passive recall. Passive recall touches +# last_recalled_at (v17), which self-defeats as a staleness signal: +# frequently-recalled-but-wrong facts never age out. Ratification is the +# distinct, intentional "still true" signal that returns an expiring +# fact to active and resets both clocks. +# - expiring_since: set when the sweeper moves an active fact to +# "expiring" (stale past threshold). Starts the ratification window; +# after ratify_window_days without ratification the fact becomes +# "expired" (excluded from default recall, never deleted, restorable). +Sequel.migration do + up do + alter_table(:facts) do + add_column :reaffirmed_at, String # ISO 8601, explicit ratification only + add_column :expiring_since, String # ISO 8601, entered expiring stage + end + + run "CREATE INDEX IF NOT EXISTS idx_facts_expiring_since ON facts(expiring_since)" + end + + down do + alter_table(:facts) do + drop_column :reaffirmed_at + drop_column :expiring_since + end + end +end diff --git a/lib/claude_memory/store/schema_manager.rb b/lib/claude_memory/store/schema_manager.rb index e2f8b58..e1dd94b 100644 --- a/lib/claude_memory/store/schema_manager.rb +++ b/lib/claude_memory/store/schema_manager.rb @@ -5,7 +5,7 @@ module Store # Schema migration and version management for SQLiteStore. # Handles Sequel migrations, legacy version syncing, and initial setup. module SchemaManager - SCHEMA_VERSION = 20 + SCHEMA_VERSION = 21 private diff --git a/lib/claude_memory/sweep/maintenance.rb b/lib/claude_memory/sweep/maintenance.rb index 06ad04d..c05e550 100644 --- a/lib/claude_memory/sweep/maintenance.rb +++ b/lib/claude_memory/sweep/maintenance.rb @@ -16,7 +16,9 @@ class Maintenance otel_metric_retention_days: 30, otel_event_retention_days: 14, otel_trace_retention_days: 7, - observation_info_ttl_days: 30 + observation_info_ttl_days: 30, + stale_fact_threshold_days: 180, + ratify_window_days: 30 }.freeze attr_reader :store @@ -36,6 +38,36 @@ def expire_proposed_facts .update(status: "expired") end + # Stage 1 of the two-stage expiry lifecycle (#14): active → expiring. + # Uses the StaleDetector predicate (created + last recall both past the + # threshold) plus reaffirmed_at — an explicitly ratified fact is fresh + # even if nothing has recalled it. Expiring facts still recall + # (annotated, down-weighted); this only starts the ratification clock. + # Returns: Integer count of facts moved to expiring + def mark_expiring_facts + cutoff = cutoff_time(@config[:stale_fact_threshold_days]) + @store.facts + .where(status: "active") + .where { created_at < cutoff } + .where { (last_recalled_at < cutoff) | {last_recalled_at: nil} } + .where { (reaffirmed_at < cutoff) | {reaffirmed_at: nil} } + .update(status: "expiring", expiring_since: Time.now.utc.iso8601) + end + + # Stage 2 (#14): expiring → expired after ratify_window_days without + # ratification. Ratifying returns a fact to active and clears + # expiring_since, so anything still expiring past the window is + # unratified by definition. Expired facts are excluded from default + # recall (like superseded) but never deleted. + # Returns: Integer count of expired facts + def expire_unratified_facts + cutoff = cutoff_time(@config[:ratify_window_days]) + @store.facts + .where(status: "expiring") + .where { expiring_since < cutoff } + .update(status: "expired") + end + # Expire disputed facts older than TTL. # Returns: Integer count of expired facts def expire_disputed_facts diff --git a/lib/claude_memory/sweep/sweeper.rb b/lib/claude_memory/sweep/sweeper.rb index 9cdf8d9..83a41ee 100644 --- a/lib/claude_memory/sweep/sweeper.rb +++ b/lib/claude_memory/sweep/sweeper.rb @@ -12,6 +12,8 @@ class Sweeper otel_event_retention_days: 14, otel_trace_retention_days: 7, observation_info_ttl_days: 30, + stale_fact_threshold_days: 180, + ratify_window_days: 30, default_budget_seconds: 5 }.freeze @@ -42,6 +44,8 @@ def run!(budget_seconds: nil) run_if_within_budget { @stats[:proposed_facts_expired] = maintenance.expire_proposed_facts } run_if_within_budget { @stats[:disputed_facts_expired] = maintenance.expire_disputed_facts } + run_if_within_budget { @stats[:facts_marked_expiring] = maintenance.mark_expiring_facts } + run_if_within_budget { @stats[:unratified_facts_expired] = maintenance.expire_unratified_facts } run_if_within_budget { @stats[:multi_value_facts_merged] = maintenance.dedupe_multi_value_facts } run_if_within_budget { @stats[:scope_leakage_fixed] = maintenance.fix_scope_leakage } run_if_within_budget { @stats[:orphaned_provenance_deleted] = maintenance.prune_orphaned_provenance } diff --git a/spec/claude_memory/sweep/maintenance_spec.rb b/spec/claude_memory/sweep/maintenance_spec.rb index a7ff84d..c90cc63 100644 --- a/spec/claude_memory/sweep/maintenance_spec.rb +++ b/spec/claude_memory/sweep/maintenance_spec.rb @@ -14,7 +14,7 @@ FileUtils.rm_f(db_path) end - def create_fact(status:, days_ago:) + def create_fact(status:, days_ago:, **attrs) entity_id = store.find_or_create_entity(type: "repo", name: "test") created_at = (Time.now - days_ago * 86400).utc.iso8601 store.facts.insert( @@ -22,10 +22,15 @@ def create_fact(status:, days_ago:) predicate: "test_pred", object_literal: "test_obj", status: status, - created_at: created_at + created_at: created_at, + **attrs ) end + def days_ago_iso(days) + (Time.now - days * 86400).utc.iso8601 + end + def create_content(days_ago:) ingested_at = (Time.now - days_ago * 86400).utc.iso8601 store.content_items.insert( @@ -55,6 +60,54 @@ def create_content(days_ago:) end end + describe "#mark_expiring_facts" do + it "moves stale active facts to expiring and stamps expiring_since" do + id = create_fact(status: "active", days_ago: 200) + expect(maintenance.mark_expiring_facts).to eq(1) + row = store.facts.where(id: id).first + expect(row[:status]).to eq("expiring") + expect(row[:expiring_since]).not_to be_nil + end + + it "does not touch recently recalled facts" do + create_fact(status: "active", days_ago: 200, last_recalled_at: days_ago_iso(5)) + expect(maintenance.mark_expiring_facts).to eq(0) + end + + it "treats recent ratification as freshness even without recall" do + create_fact(status: "active", days_ago: 200, reaffirmed_at: days_ago_iso(5)) + expect(maintenance.mark_expiring_facts).to eq(0) + end + + it "ages out facts whose ratification is itself stale" do + create_fact(status: "active", days_ago: 400, reaffirmed_at: days_ago_iso(200)) + expect(maintenance.mark_expiring_facts).to eq(1) + end + + it "does not touch fresh facts" do + create_fact(status: "active", days_ago: 5) + expect(maintenance.mark_expiring_facts).to eq(0) + end + end + + describe "#expire_unratified_facts" do + it "expires facts past the ratification window" do + id = create_fact(status: "expiring", days_ago: 300, expiring_since: days_ago_iso(45)) + expect(maintenance.expire_unratified_facts).to eq(1) + expect(store.facts.where(id: id).first[:status]).to eq("expired") + end + + it "leaves facts still inside the window" do + create_fact(status: "expiring", days_ago: 300, expiring_since: days_ago_iso(5)) + expect(maintenance.expire_unratified_facts).to eq(0) + end + + it "ignores non-expiring facts" do + create_fact(status: "active", days_ago: 300) + expect(maintenance.expire_unratified_facts).to eq(0) + end + end + describe "#expire_disputed_facts" do it "returns count of expired facts" do create_fact(status: "disputed", days_ago: 35) From 6a7047f33140a1d78521037fb89b87d656e36c8d Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:38:22 -0400 Subject: [PATCH 2/5] Decouple decay clock from passive recall; surface expiring facts (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mark_expiring_facts no longer counts last_recalled_at as freshness: RecallTimestampRefresher bulk-touches that column on every passive recall, which self-defeated decay. Clock = created_at + reaffirmed_at. - StalenessAnnotator: expiring lifecycle marker (any predicate) that outranks the heuristic stale marker — the ratification prompt. - build_facts_dataset selects reaffirmed_at + expiring_since; shortcuts fetch includes expiring facts alongside active. Co-Authored-By: Claude Fable 5 --- lib/claude_memory/core/fact_query_builder.rb | 2 ++ .../recall/staleness_annotator.rb | 22 +++++++++++++++++-- lib/claude_memory/shortcuts.rb | 2 +- lib/claude_memory/sweep/maintenance.rb | 13 ++++++----- .../recall/staleness_annotator_spec.rb | 19 ++++++++++++++++ spec/claude_memory/sweep/maintenance_spec.rb | 4 ++-- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/lib/claude_memory/core/fact_query_builder.rb b/lib/claude_memory/core/fact_query_builder.rb index 50d584a..83314d6 100644 --- a/lib/claude_memory/core/fact_query_builder.rb +++ b/lib/claude_memory/core/fact_query_builder.rb @@ -142,6 +142,8 @@ def self.build_facts_dataset(store) Sequel[:facts][:valid_to], Sequel[:facts][:created_at], Sequel[:facts][:last_recalled_at], + Sequel[:facts][:reaffirmed_at], + Sequel[:facts][:expiring_since], Sequel[:entities][:canonical_name].as(:subject_name), Sequel[:facts][:scope], Sequel[:facts][:project_path] diff --git a/lib/claude_memory/recall/staleness_annotator.rb b/lib/claude_memory/recall/staleness_annotator.rb index f136465..8209ceb 100644 --- a/lib/claude_memory/recall/staleness_annotator.rb +++ b/lib/claude_memory/recall/staleness_annotator.rb @@ -34,12 +34,19 @@ module StalenessAnnotator DEFAULT_THRESHOLD_DAYS = 180 - # @param fact [Hash] needs :predicate; reads :valid_from, :created_at, - # :last_recalled_at when present + # @param fact [Hash] needs :predicate; reads :status, :valid_from, + # :created_at, :last_recalled_at, :expiring_since when present # @param now [Time] # @param threshold_days [Integer] # @return [String, nil] marker text, or nil when not stale / not guarded def marker_for(fact, now: Time.now.utc, threshold_days: DEFAULT_THRESHOLD_DAYS) + # Lifecycle state (#14) outranks the heuristic staleness guess: an + # expiring fact is one the sweeper has already judged unratified, so + # it's flagged on every predicate, not just single-value ones — + # this marker is the ratification prompt. + expiring = expiring_marker(fact, now: now) + return expiring if expiring + return nil unless Resolve::PredicatePolicy.single?(fact[:predicate].to_s) established = parse_time(fact[:valid_from]) || parse_time(fact[:created_at]) @@ -61,6 +68,17 @@ def stale?(fact, now: Time.now.utc, threshold_days: DEFAULT_THRESHOLD_DAYS) !marker_for(fact, now: now, threshold_days: threshold_days).nil? end + # @return [String, nil] marker for facts in the expiring lifecycle + # state, or nil for any other status + def expiring_marker(fact, now: Time.now.utc) + return nil unless fact[:status].to_s == "expiring" + + since = parse_time(fact[:expiring_since]) + days = since ? ((now - since) / 86_400).round : nil + age = days ? " #{days}d ago" : "" + "⏳ expiring: unratified#{age} — reaffirm if still true, or let it expire" + end + def parse_time(value) return nil if value.nil? return value.utc if value.is_a?(Time) diff --git a/lib/claude_memory/shortcuts.rb b/lib/claude_memory/shortcuts.rb index 8e2d358..64d135d 100644 --- a/lib/claude_memory/shortcuts.rb +++ b/lib/claude_memory/shortcuts.rb @@ -90,7 +90,7 @@ def self.fetch_active_facts(store, predicates, limit) Core::FactQueryBuilder.build_facts_dataset(store) .where(Sequel[:facts][:predicate] => predicates, - Sequel[:facts][:status] => "active") + Sequel[:facts][:status] => %w[active expiring]) .reverse_order(Sequel[:facts][:id]) .limit(limit) .all diff --git a/lib/claude_memory/sweep/maintenance.rb b/lib/claude_memory/sweep/maintenance.rb index c05e550..70509ee 100644 --- a/lib/claude_memory/sweep/maintenance.rb +++ b/lib/claude_memory/sweep/maintenance.rb @@ -39,17 +39,20 @@ def expire_proposed_facts end # Stage 1 of the two-stage expiry lifecycle (#14): active → expiring. - # Uses the StaleDetector predicate (created + last recall both past the - # threshold) plus reaffirmed_at — an explicitly ratified fact is fresh - # even if nothing has recalled it. Expiring facts still recall - # (annotated, down-weighted); this only starts the ratification clock. + # The decay clock keys off created_at and reaffirmed_at ONLY — + # deliberately NOT last_recalled_at. That column is bulk-refreshed by + # RecallTimestampRefresher on every passive recall/context-injection + # touch, so counting it as freshness self-defeats decay: any fact the + # hook keeps injecting would never expire, used or not. Ratification + # (reaffirmed_at) is the explicit signal; mere surfacing is not. + # Expiring facts still recall (annotated, down-weighted); this only + # starts the ratification clock. # Returns: Integer count of facts moved to expiring def mark_expiring_facts cutoff = cutoff_time(@config[:stale_fact_threshold_days]) @store.facts .where(status: "active") .where { created_at < cutoff } - .where { (last_recalled_at < cutoff) | {last_recalled_at: nil} } .where { (reaffirmed_at < cutoff) | {reaffirmed_at: nil} } .update(status: "expiring", expiring_since: Time.now.utc.iso8601) end diff --git a/spec/claude_memory/recall/staleness_annotator_spec.rb b/spec/claude_memory/recall/staleness_annotator_spec.rb index ed85ccb..8915cf3 100644 --- a/spec/claude_memory/recall/staleness_annotator_spec.rb +++ b/spec/claude_memory/recall/staleness_annotator_spec.rb @@ -37,6 +37,25 @@ def fact(overrides = {}) expect(described_class.marker_for(fact(last_recalled_at: recent_date), now: now)).to be_nil end + it "flags expiring facts on any predicate, with days since expiring_since" do + f = fact(predicate: "convention", status: "expiring", expiring_since: "2026-05-18T00:00:00Z") + marker = described_class.marker_for(f, now: now) + expect(marker).to include("expiring") + expect(marker).to include("10d ago") + expect(marker).to include("reaffirm") + end + + it "prefers the expiring marker over the heuristic stale marker" do + marker = described_class.marker_for(fact(status: "expiring"), now: now) + expect(marker).to include("expiring") + expect(marker).not_to include("verify before relying") + end + + it "does not treat active or expired statuses as expiring" do + expect(described_class.marker_for(fact(status: "active", predicate: "convention"), now: now)).to be_nil + expect(described_class.marker_for(fact(status: "expired", predicate: "convention"), now: now)).to be_nil + end + it "flags when last_recalled_at is also old" do expect(described_class.marker_for(fact(last_recalled_at: old_date), now: now)).not_to be_nil end diff --git a/spec/claude_memory/sweep/maintenance_spec.rb b/spec/claude_memory/sweep/maintenance_spec.rb index c90cc63..a02b705 100644 --- a/spec/claude_memory/sweep/maintenance_spec.rb +++ b/spec/claude_memory/sweep/maintenance_spec.rb @@ -69,9 +69,9 @@ def create_content(days_ago:) expect(row[:expiring_since]).not_to be_nil end - it "does not touch recently recalled facts" do + it "ignores passive recall — a recently recalled but unratified fact still decays" do create_fact(status: "active", days_ago: 200, last_recalled_at: days_ago_iso(5)) - expect(maintenance.mark_expiring_facts).to eq(0) + expect(maintenance.mark_expiring_facts).to eq(1) end it "treats recent ratification as freshness even without recall" do From ca668bad736cb0c871428ecb8e9a2864aa8dbd54 Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:17:51 -0400 Subject: [PATCH 3/5] Add MCP ratification surface: list_expiring_facts + ratify_fact (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ratification is the only signal that resets the decay clock — passive recall never does. ratify_fact also restores expired facts; superseded/ rejected/disputed are refused. --- .../mcp/handlers/management_handlers.rb | 48 +++++++++++++++++++ lib/claude_memory/mcp/tool_definitions.rb | 25 ++++++++++ lib/claude_memory/mcp/tools.rb | 2 + lib/claude_memory/store/sqlite_store.rb | 30 ++++++++++++ 4 files changed, 105 insertions(+) diff --git a/lib/claude_memory/mcp/handlers/management_handlers.rb b/lib/claude_memory/mcp/handlers/management_handlers.rb index 088da26..75ea601 100644 --- a/lib/claude_memory/mcp/handlers/management_handlers.rb +++ b/lib/claude_memory/mcp/handlers/management_handlers.rb @@ -184,6 +184,54 @@ def reject_fact(args) } end + def list_expiring_facts(args) + scope = args["scope"] || "project" + store = get_store_for_scope(scope) + return {error: "Database not available"} unless store + + rows = store.expiring_facts(limit: args["limit"] || 50) + { + scope: scope, + count: rows.size, + facts: rows.map do |r| + { + id: r[:id], + docid: r[:docid], + predicate: r[:predicate], + object: r[:object_literal], + expiring_since: r[:expiring_since] + } + end + } + end + + def ratify_fact(args) + scope = args["scope"] || "project" + store = get_store_for_scope(scope) + return {error: "Database not available"} unless store + + fact_id = args["fact_id"] + if fact_id.nil? && args["docid"] + row = store.find_fact_by_docid(args["docid"]) + fact_id = row && row[:id] + end + return {error: "fact_id or docid required"} if fact_id.nil? + + result = store.ratify_fact(fact_id) + return {error: "Fact #{fact_id} not found in #{scope} database"} if result.nil? + unless result[:ratified] + return {error: "Fact #{fact_id} is #{result[:status]} — only active, expiring, or expired facts can be ratified"} + end + + { + success: true, + scope: scope, + fact_id: fact_id, + previous_status: result[:previous_status], + message: "Fact ratified — decay clock reset" + } + end + def sweep_now(args) scope = args["scope"] || "project" store = get_store_for_scope(scope) diff --git a/lib/claude_memory/mcp/tool_definitions.rb b/lib/claude_memory/mcp/tool_definitions.rb index 8764d1a..3909d9e 100644 --- a/lib/claude_memory/mcp/tool_definitions.rb +++ b/lib/claude_memory/mcp/tool_definitions.rb @@ -227,6 +227,31 @@ def self.all }, annotations: WRITE_IDEMPOTENT }, + { + name: "memory.list_expiring_facts", + description: "List facts in the expiring window — stale, awaiting ratification before they expire. Raise these with the user mid-session ('still true that X?'): user presence is the cheapest honest freshness signal.", + inputSchema: { + type: "object", + properties: { + scope: {type: "string", enum: ["project", "global"], description: "Database scope", default: "project"}, + limit: {type: "integer", description: "Max facts to return", default: 50} + } + }, + annotations: READ_ONLY + }, + { + name: "memory.ratify_fact", + description: "Ratify a fact the user confirms is still true. Returns it to 'active' and resets the decay clock (reaffirmed_at) — the only signal that does; passive recall never resets expiry. Also restores 'expired' facts.", + inputSchema: { + type: "object", + properties: { + fact_id: {type: "integer", description: "Fact ID to ratify"}, + docid: {type: "string", description: "8-char docid (alternative to fact_id)"}, + scope: {type: "string", enum: ["project", "global"], description: "Database scope", default: "project"} + } + }, + annotations: WRITE_IDEMPOTENT + }, { name: "memory.store_extraction", description: "Store extracted facts, entities, and decisions from a conversation. Call this to persist knowledge you've learned during the session.", diff --git a/lib/claude_memory/mcp/tools.rb b/lib/claude_memory/mcp/tools.rb index 673ff6a..9b575ad 100644 --- a/lib/claude_memory/mcp/tools.rb +++ b/lib/claude_memory/mcp/tools.rb @@ -84,6 +84,8 @@ def dispatch(name, arguments) when "memory.stats" then stats(arguments) when "memory.promote" then promote(arguments) when "memory.reject_fact" then reject_fact(arguments) + when "memory.list_expiring_facts" then list_expiring_facts(arguments) + when "memory.ratify_fact" then ratify_fact(arguments) when "memory.store_extraction" then store_extraction(arguments) when "memory.decisions" then decisions(arguments) when "memory.conventions" then conventions(arguments) diff --git a/lib/claude_memory/store/sqlite_store.rb b/lib/claude_memory/store/sqlite_store.rb index 5bb0533..c40a28f 100644 --- a/lib/claude_memory/store/sqlite_store.rb +++ b/lib/claude_memory/store/sqlite_store.rb @@ -449,6 +449,36 @@ def reject_fact(fact_id, reason: nil) {rejected: true, conflicts_resolved: resolved} end + # Ratify a fact: the explicit freshness signal per #14. Returns the + # fact to "active" and resets the decay clock (reaffirmed_at). Passive + # recall never does this — ratification is the only clock reset. + # Expired facts are restorable through the same path; superseded, + # rejected, and disputed facts are not (they left the lifecycle for + # reasons ratification doesn't answer). + def ratify_fact(fact_id) + row = facts.where(id: fact_id).first + return nil unless row + unless %w[active expiring expired].include?(row[:status]) + return {ratified: false, status: row[:status]} + end + + facts.where(id: fact_id).update( + status: "active", + reaffirmed_at: Time.now.utc.iso8601, + expiring_since: nil + ) + {ratified: true, previous_status: row[:status]} + end + + # Facts awaiting ratification, oldest first (closest to expiry). + def expiring_facts(limit: 50) + facts + .where(status: "expiring") + .order(:expiring_since) + .limit(limit) + .all + end + # Retrieve active facts that have stored embeddings. # @param limit [Integer] maximum rows to return # @return [Array] fact rows with :id, :subject_entity_id, From 9ffb856ffa3598dfa9525b738a2a1d1290916d9f Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:06:44 -0400 Subject: [PATCH 4/5] Add CLI ratify command (#14) --- lib/claude_memory.rb | 1 + lib/claude_memory/commands/ratify_command.rb | 65 ++++++++++++++++++++ lib/claude_memory/commands/registry.rb | 1 + 3 files changed, 67 insertions(+) create mode 100644 lib/claude_memory/commands/ratify_command.rb diff --git a/lib/claude_memory.rb b/lib/claude_memory.rb index c768037..a41d282 100644 --- a/lib/claude_memory.rb +++ b/lib/claude_memory.rb @@ -77,6 +77,7 @@ class Error < StandardError; end require_relative "claude_memory/commands/completion_command" require_relative "claude_memory/commands/embeddings_command" require_relative "claude_memory/commands/reject_command" +require_relative "claude_memory/commands/ratify_command" require_relative "claude_memory/commands/observations_command" require_relative "claude_memory/commands/restore_command" require_relative "claude_memory/commands/dedupe_conflicts_command" diff --git a/lib/claude_memory/commands/ratify_command.rb b/lib/claude_memory/commands/ratify_command.rb new file mode 100644 index 0000000..04c256d --- /dev/null +++ b/lib/claude_memory/commands/ratify_command.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "optparse" + +module ClaudeMemory + module Commands + # Reaffirm a fact as still true, resetting its decay clock. + # Restores expiring/expired facts to active (#14). + class RatifyCommand < BaseCommand + # @param args [Array] command line arguments (fact_id_or_docid, --scope) + # @return [Integer] exit code (0 for success, 1 for failure) + def call(args) + opts = parse_options(args, {scope: "project"}) do |o| + OptionParser.new do |parser| + parser.banner = "Usage: claude-memory ratify [options]" + parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v } + end + end + return 1 if opts.nil? + + identifier = args.first + return failure("Usage: claude-memory ratify [options]") if identifier.nil? || identifier.empty? + + manager = ClaudeMemory::Store::StoreManager.new + store = manager.store_for_scope(opts[:scope]) + + fact_id = resolve_fact_id(store, identifier) + unless fact_id + stderr.puts "Fact '#{identifier}' not found in #{opts[:scope]} database." + manager.close + return 1 + end + + result = store.ratify_fact(fact_id) + manager.close + + if result.nil? + stderr.puts "Fact ##{fact_id} not found." + return 1 + end + + unless result[:ratified] + stderr.puts "Fact ##{fact_id} is #{result[:status]} — only active, expiring, or expired facts can be ratified." + return 1 + end + + stdout.puts "Ratified fact ##{fact_id} in #{opts[:scope]} database (was #{result[:previous_status]}) — decay clock reset." + 0 + end + + private + + # Accept either a numeric fact id or an 8-char docid hex string. + # @param store [Store::SQLiteStore] database to look up the fact in + # @param identifier [String] numeric fact id or hex docid + # @return [Integer, nil] resolved fact id, or nil if not found + def resolve_fact_id(store, identifier) + return identifier.to_i if identifier.match?(/\A\d+\z/) + + row = store.find_fact_by_docid(identifier) + row ? row[:id] : nil + end + end + end +end diff --git a/lib/claude_memory/commands/registry.rb b/lib/claude_memory/commands/registry.rb index f69f08f..86d234c 100644 --- a/lib/claude_memory/commands/registry.rb +++ b/lib/claude_memory/commands/registry.rb @@ -37,6 +37,7 @@ class Registry "completion" => {class: CompletionCommand, description: "Generate shell completions"}, "embeddings" => {class: EmbeddingsCommand, description: "Inspect embedding backend"}, "reject" => {class: RejectCommand, description: "Mark a fact as rejected"}, + "ratify" => {class: RatifyCommand, description: "Reaffirm a fact, resetting its decay clock"}, "observations" => {class: ObservationsCommand, description: "Inspect, promote, or consolidate episodic observations"}, "restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"}, "dedupe-conflicts" => {class: DedupeConflictsCommand, description: "Deduplicate historical open conflict rows that describe the same pair"}, From 33ad0c752a913448f078c2debda68ee6d031ccfb Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:47:38 -0400 Subject: [PATCH 5/5] Surface expiring facts on the dashboard (#14) Co-Authored-By: Claude Fable 5 --- lib/claude_memory/dashboard/health.rb | 5 ++++- lib/claude_memory/dashboard/knowledge.rb | 10 +++++++--- spec/claude_memory/dashboard/knowledge_spec.rb | 16 ++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/claude_memory/dashboard/health.rb b/lib/claude_memory/dashboard/health.rb index f3f8c5c..4583ff3 100644 --- a/lib/claude_memory/dashboard/health.rb +++ b/lib/claude_memory/dashboard/health.rb @@ -51,10 +51,13 @@ def db_check(label, path) store = @manager.store_for_scope(label) version = store.schema_version + expiring = store.facts.where(status: "expiring").count + message = "Schema v#{version}, #{store.facts.where(status: "active").count} active facts" + message += ", #{expiring} expiring (awaiting ratification)" if expiring.positive? { name: "#{label}_database", status: "healthy", - message: "Schema v#{version}, #{store.facts.where(status: "active").count} active facts" + message: message } rescue => e { diff --git a/lib/claude_memory/dashboard/knowledge.rb b/lib/claude_memory/dashboard/knowledge.rb index d7528dc..0964513 100644 --- a/lib/claude_memory/dashboard/knowledge.rb +++ b/lib/claude_memory/dashboard/knowledge.rb @@ -66,7 +66,11 @@ def summary(params = {}) section: section_filter, totals: { project: count_for_scope("project"), - global: count_for_scope("global") + global: count_for_scope("global"), + expiring: { + project: count_for_scope("project", status: "expiring"), + global: count_for_scope("global", status: "expiring") + } }, sections: sections } @@ -74,10 +78,10 @@ def summary(params = {}) private - def count_for_scope(scope) + def count_for_scope(scope, status: "active") store = @manager.store_if_exists(scope) return 0 unless store - store.facts.where(status: "active").count + store.facts.where(status: status).count rescue Sequel::DatabaseError 0 end diff --git a/spec/claude_memory/dashboard/knowledge_spec.rb b/spec/claude_memory/dashboard/knowledge_spec.rb index 0f9f0bb..c0a6e29 100644 --- a/spec/claude_memory/dashboard/knowledge_spec.rb +++ b/spec/claude_memory/dashboard/knowledge_spec.rb @@ -24,18 +24,18 @@ FileUtils.rm_rf(tmpdir) end - def insert(store, predicate:, object:, subject: "app", scope: "project", confidence: 0.9) + def insert(store, predicate:, object:, subject: "app", scope: "project", confidence: 0.9, status: "active") entity_id = store.find_or_create_entity(type: "repo", name: subject) store.insert_fact( subject_entity_id: entity_id, predicate: predicate, object_literal: object, - status: "active", confidence: confidence, scope: scope + status: status, confidence: confidence, scope: scope ) end describe "#summary" do it "returns the zero shape when empty" do data = knowledge.summary - expect(data[:totals]).to eq(project: 0, global: 0) + expect(data[:totals]).to eq(project: 0, global: 0, expiring: {project: 0, global: 0}) expect(data[:sections].map { |s| s[:key] }).to eq([ :decisions, :quality_guards, :conventions, :architecture, :constraints, :references ]) @@ -122,7 +122,15 @@ def insert(store, predicate:, object:, subject: "app", scope: "project", confide insert(manager.global_store, predicate: "convention", object: "y", scope: "global") data = knowledge.summary - expect(data[:totals]).to eq(project: 1, global: 1) + expect(data[:totals]).to eq(project: 1, global: 1, expiring: {project: 0, global: 0}) + end + + it "counts expiring facts separately from active totals" do + insert(manager.project_store, predicate: "decision", object: "x") + insert(manager.project_store, predicate: "convention", object: "y", status: "expiring") + + data = knowledge.summary + expect(data[:totals]).to eq(project: 1, global: 0, expiring: {project: 1, global: 0}) end end end