From 936bfb2d8df88bb133ece572be6d2fb7e81cf2e3 Mon Sep 17 00:00:00 2001 From: Balaji Janakiram Date: Fri, 10 Apr 2026 19:56:49 +0530 Subject: [PATCH] Rebuild rules index at SessionStart when decision files are newer The rules index (.claude/rules/decisions.md) only updated via PostToolUse when Claude Code's Write tool wrote decision files. Files created outside Claude Code (manual edits, git pull, other tools) left the index stale. Now session_init checks if any decision file is newer than the index and regenerates it before the session begins. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/decision/policy/session_init.py | 37 ++++++++++ tests/test_session_init.py | 106 ++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 tests/test_session_init.py diff --git a/src/decision/policy/session_init.py b/src/decision/policy/session_init.py index dc8f9bf..41f53bd 100644 --- a/src/decision/policy/session_init.py +++ b/src/decision/policy/session_init.py @@ -8,11 +8,48 @@ from .engine import PolicyResult, SessionState +def _rebuild_index_if_stale(state: SessionState) -> None: + """Regenerate .claude/rules/decisions.md if any decision file is newer than the index.""" + try: + store = state.get_store() + rules_file = store.decisions_dir.parent / "rules" / "decisions.md" + + # If the index doesn't exist, rebuild unconditionally + if not rules_file.is_file(): + if store.decision_count() == 0: + return # nothing to index + from .index_update import _generate_index + + rules_file.parent.mkdir(parents=True, exist_ok=True) + rules_file.write_text(_generate_index(store)) + return + + index_mtime = rules_file.stat().st_mtime + + # Check if any decision file is newer than the index + for f in store.decisions_dir.glob("*.md"): + try: + if f.stat().st_mtime > index_mtime: + from .index_update import _generate_index + + new_content = _generate_index(store) + if new_content.strip() != rules_file.read_text().strip(): + rules_file.write_text(new_content) + return + except OSError: + continue + except Exception as exc: + print(f"decision: _rebuild_index_if_stale error: {exc}", file=sys.stderr) + + def _session_init_condition(data: dict[str, Any], state: SessionState) -> PolicyResult | None: """Initialize decision store and print banner at session start.""" store = state.get_store() store.ensure_dir() + # Rebuild rules index if decision files changed outside Claude Code + _rebuild_index_if_stale(state) + # Opportunistically clean up stale session dirs from /tmp SessionState.cleanup_stale(max_age_seconds=14400) # 4 hours diff --git a/tests/test_session_init.py b/tests/test_session_init.py new file mode 100644 index 0000000..edce2ac --- /dev/null +++ b/tests/test_session_init.py @@ -0,0 +1,106 @@ +"""Session init policy tests — index rebuild on stale rules file.""" + +import time + +from conftest import make_decision, make_session_state, make_store + + +# ── Index rebuild at session start ───────────────────────────────── + + +def test_rebuild_index_when_no_rules_file(tmp_path): + """Index is created when decisions exist but rules/decisions.md is missing.""" + decisions_dir, store = make_store(tmp_path) + from decision.policy.session_init import _rebuild_index_if_stale + + make_decision(decisions_dir, "test-dec", tags=["testing"]) + + state = make_session_state("rebuild-missing", store=store) + rules_file = decisions_dir.parent / "rules" / "decisions.md" + assert not rules_file.exists() + + _rebuild_index_if_stale(state) + + assert rules_file.is_file() + content = rules_file.read_text() + assert "test-dec" in content + + +def test_rebuild_index_when_decision_newer(tmp_path): + """Index is regenerated when a decision file is newer than the rules file.""" + decisions_dir, store = make_store(tmp_path) + from decision.policy.session_init import _rebuild_index_if_stale + + make_decision(decisions_dir, "old-dec", tags=["testing"]) + + # Create an initial rules file + rules_dir = decisions_dir.parent / "rules" + rules_dir.mkdir(parents=True, exist_ok=True) + rules_file = rules_dir / "decisions.md" + rules_file.write_text("# Team Decisions\n\nStale index.\n") + + # Backdate the rules file so the decision is newer + past = time.time() - 100 + import os + os.utime(rules_file, (past, past)) + + state = make_session_state("rebuild-stale", store=store) + _rebuild_index_if_stale(state) + + content = rules_file.read_text() + assert "old-dec" in content + assert "Stale index" not in content + + +def test_no_rebuild_when_index_fresh(tmp_path): + """Index is not rewritten when it's already up to date.""" + decisions_dir, store = make_store(tmp_path) + from decision.policy.session_init import _rebuild_index_if_stale + from decision.policy.index_update import _generate_index + + make_decision(decisions_dir, "fresh-dec", tags=["testing"]) + + # Create an up-to-date rules file + rules_dir = decisions_dir.parent / "rules" + rules_dir.mkdir(parents=True, exist_ok=True) + rules_file = rules_dir / "decisions.md" + rules_file.write_text(_generate_index(store)) + + # Make the rules file newer than all decisions + import os + future = time.time() + 100 + os.utime(rules_file, (future, future)) + + original_mtime = rules_file.stat().st_mtime + + state = make_session_state("rebuild-fresh", store=store) + _rebuild_index_if_stale(state) + + # File should not have been rewritten + assert rules_file.stat().st_mtime == original_mtime + + +def test_no_rebuild_when_no_decisions(tmp_path): + """No index created when there are no decisions at all.""" + _, store = make_store(tmp_path) + from decision.policy.session_init import _rebuild_index_if_stale + + state = make_session_state("rebuild-empty", store=store) + rules_file = store.decisions_dir.parent / "rules" / "decisions.md" + + _rebuild_index_if_stale(state) + + assert not rules_file.exists() + + +def test_rebuild_index_error_is_silent(tmp_path): + """Errors in _rebuild_index_if_stale don't propagate.""" + from unittest.mock import patch + from decision.policy.session_init import _rebuild_index_if_stale + + _, store = make_store(tmp_path) + state = make_session_state("rebuild-error", store=store) + + # Force an error by making decisions_dir a file instead of directory + with patch.object(store, "decision_count", side_effect=RuntimeError("boom")): + _rebuild_index_if_stale(state) # should not raise