Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .claude/hooks/observation-recorder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Stamp a knowledge source the moment a tool that reads it is called.

Runs on PostToolUse. The point is that observation recording must not depend on
the assistant choosing to record: an assistant that cannot notice its context
has gone stale is exactly the one that will forget to write down when it last
looked. This watches what actually happened instead.

Mapping is by tool name, deliberately coarse. A calendar tool means the calendar
was observed; which calendar, and whether the assistant then used the result
correctly, are different questions this does not pretend to answer.

Silent and exit 0 throughout. A vault that cannot record an observation is no
worse off than one with no ledger at all, and nothing here is worth interrupting
a tool call for.
"""
from __future__ import annotations

import json
import os
import sys
from pathlib import Path

# Substring match against the tool name, first hit wins. Ordered so that more
# specific patterns precede general ones.
TOOL_SOURCES: tuple[tuple[str, str], ...] = (
("calendar_get", "calendar"),
("calendar_search", "calendar"),
("calendar_", "calendar"),
("apple-mail", "email"),
("apple_mail", "email"),
("gmail", "email"),
("search_meetings", "meetings"),
("get_meeting", "meetings"),
("granola", "meetings"),
("wispr", "meetings"),
("list_tasks", "tasks"),
("update_task", "tasks"),
("create_task", "tasks"),
("get_week_progress", "week_priorities"),
("get_week_priorities", "week_priorities"),
("get_quarterly_goals", "quarter_goals"),
("get_goal_status", "quarter_goals"),
("pipedrive", "pipeline"),
("lookup_person", "people"),
("build_people_index", "people"),
("list_companies", "accounts"),
("refresh_company", "accounts"),
)


def _source_for(tool_name: str) -> str | None:
lowered = (tool_name or "").lower()
for needle, source in TOOL_SOURCES:
if needle in lowered:
return source
return None


def main() -> int:
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return 0
if not isinstance(payload, dict):
return 0

tool_name = ""
for key in ("tool_name", "toolName", "name", "tool"):
value = payload.get(key)
if isinstance(value, str) and value:
tool_name = value
break

source = _source_for(tool_name)
if source is None:
return 0

vault = Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
sys.path.insert(0, str(vault))
try:
from core.utils.freshness import observe
except Exception: # noqa: BLE001 - a vault without the module is not a fault
return 0
try:
observe(vault, source)
except Exception: # noqa: BLE001
return 0
return 0


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ System/my-customizations.md
!.claude/hooks/session-clock.sh
!.claude/hooks/correction-capture.sh
!.claude/hooks/correction-capture.py
!.claude/hooks/observation-recorder.py
!.claude/hooks/dex-core-orientation.sh
!.claude/hooks/ensure-mcp-user-scope.cjs
!.claude/hooks/connection-health-checker.cjs
Expand Down
50 changes: 50 additions & 0 deletions System/knowledge-half-life.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# How long an observation stays trustworthy, per source.
#
# Context carries no freshness. Everything an assistant holds presents with
# equal authority whether it was observed a minute ago or yesterday, and in a
# long session that is how a stale calendar read, an unchecked inbox, or
# yesterday's date get used as though fresh.
#
# This file makes the decay assumption explicit and per-vault, because
# volatility is personal: one person's pipeline moves weekly, another's task
# list moves hourly. Shipped as a seed, so your tuning is never overwritten by
# an update.
#
# Durations accept s, m, h, d. `never` means the observation does not decay --
# use it for things that are superseded rather than aged out.

sources:
# World state, cheap to re-observe, expensive to be wrong about.
clock: {half_life: 5m, note: "re-stated every prompt by the session-clock hook"}
calendar: {half_life: 30m}
email: {half_life: 30m}
ci: {half_life: 10m, note: "pull request and build state"}
meetings: {half_life: 1h, note: "whether new captures exist"}
transcripts: {half_life: never, note: "a finalised capture is a record of what was said; superseded by a later meeting, never aged out"}
tasks: {half_life: 4h}
timesheet: {half_life: 8h}

# Vault content: changes on a human cadence.
week_priorities: {half_life: 2d}
people: {half_life: 14d}
accounts: {half_life: 7d, note: "deal state moves faster than the person behind it"}
pipeline: {half_life: 2d}
quarter_goals: {half_life: 14d}
pillars: {half_life: 90d}

# Not observations. These are superseded, never stale.
user_decisions: {half_life: never}
user_corrections: {half_life: never}

# What a given output must have freshly observed before it is written.
#
# This is the half that can actually be enforced. An assistant cannot reliably
# audit its own memory for staleness, but "this artefact requires a calendar
# read newer than its half-life" is checkable from the outside.
artefacts:
daily-plan: {requires_fresh: [clock, calendar, email, tasks, week_priorities]}
daily-review: {requires_fresh: [clock, calendar, email, tasks, meetings]}
week-plan: {requires_fresh: [clock, calendar, tasks, week_priorities, quarter_goals]}
week-review: {requires_fresh: [clock, tasks, week_priorities, quarter_goals]}
meeting-prep: {requires_fresh: [clock, calendar, people, accounts]}
pipeline-sync: {requires_fresh: [clock, pipeline, accounts]}
50 changes: 50 additions & 0 deletions System/knowledge-half-life.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# How long an observation stays trustworthy, per source.
#
# Context carries no freshness. Everything an assistant holds presents with
# equal authority whether it was observed a minute ago or yesterday, and in a
# long session that is how a stale calendar read, an unchecked inbox, or
# yesterday's date get used as though fresh.
#
# This file makes the decay assumption explicit and per-vault, because
# volatility is personal: one person's pipeline moves weekly, another's task
# list moves hourly. Shipped as a seed, so your tuning is never overwritten by
# an update.
#
# Durations accept s, m, h, d. `never` means the observation does not decay --
# use it for things that are superseded rather than aged out.

sources:
# World state, cheap to re-observe, expensive to be wrong about.
clock: {half_life: 5m, note: "re-stated every prompt by the session-clock hook"}
calendar: {half_life: 30m}
email: {half_life: 30m}
ci: {half_life: 10m, note: "pull request and build state"}
meetings: {half_life: 1h, note: "whether new captures exist"}
transcripts: {half_life: never, note: "a finalised capture is a record of what was said; superseded by a later meeting, never aged out"}
tasks: {half_life: 4h}
timesheet: {half_life: 8h}

# Vault content: changes on a human cadence.
week_priorities: {half_life: 2d}
people: {half_life: 14d}
accounts: {half_life: 7d, note: "deal state moves faster than the person behind it"}
pipeline: {half_life: 2d}
quarter_goals: {half_life: 14d}
pillars: {half_life: 90d}

# Not observations. These are superseded, never stale.
user_decisions: {half_life: never}
user_corrections: {half_life: never}

# What a given output must have freshly observed before it is written.
#
# This is the half that can actually be enforced. An assistant cannot reliably
# audit its own memory for staleness, but "this artefact requires a calendar
# read newer than its half-life" is checkable from the outside.
artefacts:
daily-plan: {requires_fresh: [clock, calendar, email, tasks, week_priorities]}
daily-review: {requires_fresh: [clock, calendar, email, tasks, meetings]}
week-plan: {requires_fresh: [clock, calendar, tasks, week_priorities, quarter_goals]}
week-review: {requires_fresh: [clock, tasks, week_priorities, quarter_goals]}
meeting-prep: {requires_fresh: [clock, calendar, people, accounts]}
pipeline-sync: {requires_fresh: [clock, pipeline, accounts]}
3 changes: 3 additions & 0 deletions core/portable_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ def _r(rule_id: str, path: str, kind: str, ownership: str, note: str = "") -> Ru
_r("seed-pillars-live", "System/pillars.yaml", "file", "seed",
"shipped empty; user pillar registry — never overwritten"),
_r("seed-pillars-example", "System/pillars.example.yaml", "file", "seed"),
_r("seed-half-life-live", "System/knowledge-half-life.yaml", "file", "seed",
"per-vault decay assumptions; user tuning is never overwritten"),
_r("seed-half-life-example", "System/knowledge-half-life.example.yaml", "file", "seed"),
_r("seed-trusted-mcps-example", "System/trusted-mcps.example.yaml", "file", "seed"),
_r("seed-mcp-example", "System/.mcp.json.example", "file", "seed"),
_r("seed-env-example", "env.example", "file", "seed"),
Expand Down
158 changes: 158 additions & 0 deletions core/tests/test_freshness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Freshness must be a fact about the ledger, never an opinion about the assistant.

The failure these guard against: a long session where a calendar read from four
hours ago and one from a minute ago carry identical authority, so the stale one
gets used and nothing says otherwise.
"""
from __future__ import annotations

import time

import pytest

from core.utils import freshness

CONFIG = """
sources:
clock: {half_life: 5m}
calendar: {half_life: 30m}
email: {half_life: 30m}
tasks: {half_life: 4h}
week_priorities: {half_life: 2d}
user_corrections: {half_life: never}
artefacts:
daily-plan: {requires_fresh: [clock, calendar, email, tasks, week_priorities]}
"""


def _vault(tmp_path, config: str = CONFIG):
(tmp_path / "System").mkdir(parents=True, exist_ok=True)
(tmp_path / "System" / "knowledge-half-life.yaml").write_text(config, encoding="utf-8")
return tmp_path


@pytest.mark.parametrize(
("text", "seconds"),
[("45s", 45), ("30m", 1800), ("4h", 14400), ("2d", 172800), ("1.5h", 5400)],
)
def test_durations_parse(text, seconds):
assert freshness.parse_duration(text) == seconds


def test_never_is_not_a_duration_but_a_declaration():
assert freshness.parse_duration("never") is None


def test_a_typo_in_a_half_life_fails_loudly(tmp_path):
"""Silently becoming "fresh forever" is the worst possible reading of a typo."""
with pytest.raises(ValueError):
freshness.parse_duration("30 minutes")

vault = _vault(tmp_path, "sources:\n calendar: {half_life: soon}\n")
with pytest.raises(freshness.HalfLifeUnavailable):
freshness.load_config(vault)


def test_a_missing_config_is_unavailable_not_all_fresh(tmp_path):
"""Not being able to judge freshness must not read as everything being fine."""
with pytest.raises(freshness.HalfLifeUnavailable):
freshness.load_config(tmp_path)


def test_an_artefact_requiring_an_unknown_source_refuses_to_load(tmp_path):
"""Such a contract could never be satisfied, and would fail invisibly."""
vault = _vault(
tmp_path,
"sources:\n calendar: {half_life: 30m}\nartefacts:\n x: {requires_fresh: [calendar, ghost]}\n",
)
with pytest.raises(freshness.HalfLifeUnavailable, match="ghost"):
freshness.load_config(vault)


def test_a_source_never_observed_is_not_fresh(tmp_path):
vault = _vault(tmp_path)
config = freshness.load_config(vault)

assert freshness.is_fresh(config, vault, "calendar") is False
assert freshness.age_seconds(vault, "calendar") is None


def test_observation_makes_a_source_fresh_and_time_takes_it_away(tmp_path):
vault = _vault(tmp_path)
config = freshness.load_config(vault)
now = time.time()

freshness.observe(vault, "calendar", at=now)
assert freshness.is_fresh(config, vault, "calendar", now=now + 60) is True

# 30-minute half-life: 31 minutes later it is not.
assert freshness.is_fresh(config, vault, "calendar", now=now + 1860) is False


def test_a_source_that_does_not_decay_is_always_fresh(tmp_path):
"""Corrections and decisions are superseded, not aged out."""
vault = _vault(tmp_path)
config = freshness.load_config(vault)

assert freshness.is_fresh(config, vault, "user_corrections", now=time.time() + 10**7) is True


def test_an_unknown_source_gets_the_cautious_answer(tmp_path):
vault = _vault(tmp_path)
config = freshness.load_config(vault)

assert freshness.is_fresh(config, vault, "astrology") is False


def test_missing_for_names_exactly_what_the_artefact_lacks(tmp_path):
vault = _vault(tmp_path)
config = freshness.load_config(vault)
now = time.time()

freshness.observe(vault, "calendar", at=now)
freshness.observe(vault, "tasks", at=now)
freshness.observe(vault, "email", at=now - 4 * 3600) # stale

missing = freshness.missing_for(config, vault, "daily-plan", now=now)

assert set(missing) == {"clock", "email", "week_priorities"}


def test_an_artefact_with_no_contract_requires_nothing(tmp_path):
"""Not every output needs a contract; inventing one is worse than having none."""
vault = _vault(tmp_path)
config = freshness.load_config(vault)

assert freshness.missing_for(config, vault, "some-other-skill") == ()


def test_report_distinguishes_stale_from_never_observed(tmp_path):
""""Old" and "never looked" call for different responses from a reader."""
vault = _vault(tmp_path)
config = freshness.load_config(vault)
now = time.time()
freshness.observe(vault, "email", at=now - 4 * 3600)

rows = {r["source"]: r["state"] for r in freshness.report(config, vault, "daily-plan", now=now)["sources"]}

assert rows["email"] == "STALE"
assert rows["calendar"] == "NOT OBSERVED"


def test_a_corrupt_ledger_reads_as_no_observations(tmp_path):
"""A damaged ledger must not make everything look freshly observed."""
vault = _vault(tmp_path)
ledger = vault / "System" / ".dex"
ledger.mkdir(parents=True, exist_ok=True)
(ledger / "observations.json").write_text("{not json", encoding="utf-8")

assert freshness.read_ledger(vault) == {}


def test_observing_never_raises_even_when_the_ledger_cannot_be_written(tmp_path):
"""Losing an observation is survivable. Failing a tool call over one is not."""
vault = _vault(tmp_path)
(vault / "System" / ".dex").mkdir(parents=True, exist_ok=True)
(vault / "System" / ".dex" / "observations.json").mkdir()

freshness.observe(vault, "calendar") # must not raise
Loading
Loading