Skip to content

Commit 42d6042

Browse files
teslamintclaude
andauthored
feat(hooks): Replace SessionStart file-list loop with ranker (#78)
* feat(hooks): Replace SessionStart file-list loop with ranker Replace the per-file list_decisions loop in on_session_start_decisions with a single rank_related_decisions call that uses all five signals: file paths, uncommitted diff (truncated 8192 bytes), recent commit SHAs, recent assessment IDs (configurable lookback window), and quality score. Add _get_uncommitted_diff and _get_recent_commit_shas helpers. Score breakdown is included in Markdown via _format_decision_entry. No fallback — ranker has internal padding via _MIN_CANDIDATE_THRESHOLD. New config key: decisions.assessment_lookback_hours (default 48). Refs GH-72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(hooks): Normalize file paths before passing to ranker Git-relative paths (e.g. src/app.py) must be normalized via _normalize_path before passing to rank_related_decisions, which uses exact/proximity matching against stored decision_files paths. Without normalization, decisions linked with absolute or ./-prefixed paths would not match. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(test): Update stale docstring for ranker-based SessionStart The docstring referenced the removed per-file list_decisions loop and its 10-row cap. Updated to describe the current rank_related_decisions behavior with include_contradicted=False. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d3d4e5a commit 42d6042

4 files changed

Lines changed: 94 additions & 89 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Added
1515

16+
- **Relevance-based SessionStart reactivation** (#72) — SessionStart hook now uses `rank_related_decisions()` with full multi-signal ranking (file paths, uncommitted diff, recent commit SHAs, assessment IDs) instead of the per-file `list_decisions()` loop. Score breakdown is included in Markdown output. Assessment signal lookback is configurable via `decisions.assessment_lookback_hours` (default 48).
1617
- **Retrieval telemetry completeness** (#70) — PostToolUse and SessionStart hooks now record per-decision `retrieval_selections` rows alongside the existing `retrieval_events` row, threading `selection_id` into Markdown fallback files. `record_retrieval_event` and `record_retrieval_selection` in `core/telemetry.py` accept a `commit` parameter (default `True`) so hook callers can defer commits for atomicity.
1718
- **Extraction noise gate and confidence threshold** (#71) — `maybe_extract_decisions` now checks session quality before launching the extraction worker: sessions must have at least 1 checkpoint OR a configurable minimum number of turns with `files_touched` (`decisions.noise_gate_min_turns_with_files`, default 3). Additionally, `run_extraction` filters candidates below `decisions.candidate_min_confidence` (raised from 0.0 to 0.35) before persistence, preventing low-quality session-source candidates with no rationale or alternatives from entering the candidate pipeline.
1819
- **Contract-sync drift guards**`tests/test_contract_sync.py` asserts `mcp/server.__all__` matches what `register_tools()` actually registers (driven by AST extraction of `server.py`'s module tuple, not a hardcoded copy), that every `ec_*` tool is present in the README `### Available Tools` section bidirectionally (catches stale rows as well as missing rows), that `decision_hooks` fallback filename constants are documented in README, and that the current `SCHEMA_VERSION` is cross-referenced in a CHANGELOG paragraph that also mentions "schema". Replaces `tests/test_mcp_registration.py`, whose hardcoded expected set had silently drifted (its registration loop omitted `tools.decision_candidates` and its expected set omitted the four candidate tools, so it passed via symmetric drift — the exact failure mode the v0.2.0 retrospective finding #2 named).

src/entirecontext/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"auto_extract": False,
7272
"show_related_on_start": False,
7373
"auto_promotion_contradicted_threshold": 2,
74+
"assessment_lookback_hours": 48,
7475
"surface_on_tool_use": False,
7576
"surface_on_tool_use_turn_interval": 1,
7677
"surface_on_tool_use_limit": 3,

src/entirecontext/hooks/decision_hooks.py

Lines changed: 76 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,40 @@ def _post_tool_fallback_name(session_id: str) -> str:
4242
return f"{_POST_TOOL_FALLBACK_BASE}-{safe}.md"
4343

4444

45+
def _get_uncommitted_diff(repo_path: str) -> str | None:
46+
"""Return uncommitted diff text, truncated to 8192 bytes. Returns None on failure."""
47+
try:
48+
result = subprocess.run(
49+
["git", "diff", "HEAD"],
50+
cwd=repo_path,
51+
capture_output=True,
52+
text=True,
53+
timeout=5,
54+
)
55+
if result.returncode == 0 and result.stdout.strip():
56+
return result.stdout[:8192]
57+
except (subprocess.TimeoutExpired, FileNotFoundError):
58+
pass
59+
return None
60+
61+
62+
def _get_recent_commit_shas(repo_path: str, limit: int = 5) -> list[str]:
63+
"""Return recent commit SHAs. Returns empty list on failure."""
64+
try:
65+
result = subprocess.run(
66+
["git", "log", "--format=%H", f"-{limit}"],
67+
cwd=repo_path,
68+
capture_output=True,
69+
text=True,
70+
timeout=5,
71+
)
72+
if result.returncode == 0 and result.stdout.strip():
73+
return [s for s in result.stdout.strip().split("\n") if s]
74+
except (subprocess.TimeoutExpired, FileNotFoundError):
75+
pass
76+
return []
77+
78+
4579
def _load_decisions_config(repo_path: str) -> dict:
4680
from ..core.config import load_config
4781

@@ -117,6 +151,13 @@ def _format_decision_entry(d: dict, stale: bool = False) -> str:
117151
parts.append(f" Files: {files}")
118152
if rationale_short:
119153
parts.append(f" Rationale: {rationale_short}")
154+
if d.get("score") is not None and d.get("score_breakdown"):
155+
sb = d["score_breakdown"]
156+
parts.append(
157+
f" Score: {d['score']:.1f}"
158+
f" (file={sb.get('file_exact', 0)}+{sb.get('file_proximity', 0)},"
159+
f" diff={sb.get('diff_relevance', 0)}, quality={sb.get('quality', 0)})"
160+
)
120161
if d.get("selection_id"):
121162
parts.append(f" Selection: {d['selection_id']}")
122163
return "\n".join(parts)
@@ -134,12 +175,7 @@ def on_session_start_decisions(data: dict[str, Any]) -> str | None:
134175
if not config.get("show_related_on_start", False):
135176
return None
136177

137-
from ..core.decisions import (
138-
_apply_staleness_policy,
139-
get_decision,
140-
list_decisions,
141-
resolve_successor_chain,
142-
)
178+
from ..core.decisions import _normalize_path, get_decision, list_decisions, rank_related_decisions
143179
from ..db import get_db
144180

145181
conn = get_db(repo_path)
@@ -148,65 +184,44 @@ def on_session_start_decisions(data: dict[str, Any]) -> str | None:
148184
seen_ids: set[str] = set()
149185
display_limit = 5
150186

151-
# 1. Recently changed files → linked decisions.
152-
# Use `list_decisions(file_path=f)` per changed file so path matching
153-
# preserves the existing LIKE-contains semantics (handles `./src/app.py`
154-
# vs `src/app.py` divergence between git output and stored decision_files).
155-
# Staleness policy: contradicted rows are dropped by the policy filter,
156-
# but superseded rows are intentionally kept so the loop below can walk
157-
# their supersession chain and substitute the terminal successor.
187+
# 1. Assemble signals and rank decisions via the full multi-signal ranker.
158188
changed_files = _get_recently_changed_files(repo_path)
159-
file_related = []
160-
if changed_files:
161-
raw_seen: set[str] = set()
162-
for f in changed_files:
163-
if len(seen_ids) >= display_limit:
164-
break
165-
166-
# Push contradicted-exclusion down to SQL so the limit=10
167-
# row cap can't hide fresh/superseded candidates behind a
168-
# wall of contradicted rows (PR #55 Codex review).
169-
file_rows: list[dict] = []
170-
for d in list_decisions(conn, file_path=f, limit=10, include_contradicted=False):
171-
if d["id"] in raw_seen:
172-
continue
173-
raw_seen.add(d["id"])
174-
file_rows.append(d)
175-
176-
if not file_rows:
177-
continue
189+
diff_text = _get_uncommitted_diff(repo_path)
190+
commit_shas = _get_recent_commit_shas(repo_path, limit=5)
178191

179-
# SQL already dropped contradicted rows; the policy call
180-
# still enforces `include_superseded=True` so the chain
181-
# collapse branch below can substitute each one with its
182-
# terminal successor.
183-
kept, _stats = _apply_staleness_policy(
184-
file_rows,
185-
include_stale=True,
186-
include_superseded=True,
187-
include_contradicted=False,
188-
)
189-
for row in kept:
190-
if row["id"] in seen_ids:
191-
continue
192-
effective_id = row["id"]
193-
if row.get("staleness_status") == "superseded":
194-
if not row.get("superseded_by_id"):
195-
# No successor pointer — hide this orphaned record.
196-
continue
197-
terminal_id, terminal_status = resolve_successor_chain(conn, row["id"])
198-
if terminal_id == row["id"] or terminal_status in ("contradicted", "superseded"):
199-
# Unresolved chain or terminal is also filtered — skip.
200-
continue
201-
effective_id = terminal_id
202-
if effective_id in seen_ids:
203-
continue
204-
full = get_decision(conn, effective_id)
192+
assessment_ids: list[str] = []
193+
try:
194+
from ..core.futures import list_assessments
195+
from datetime import datetime, timedelta, timezone
196+
197+
lookback_hours = config.get("assessment_lookback_hours", 48)
198+
since = (datetime.now(timezone.utc) - timedelta(hours=lookback_hours)).isoformat()
199+
recent_assessments = list_assessments(conn, limit=20, since=since)
200+
assessment_ids = [a["id"] for a in recent_assessments]
201+
except Exception:
202+
pass
203+
204+
normalized_files = [_normalize_path(f) for f in changed_files if _normalize_path(f)]
205+
206+
file_related: list[dict] = []
207+
if normalized_files or diff_text or commit_shas or assessment_ids:
208+
ranked = rank_related_decisions(
209+
conn,
210+
file_paths=normalized_files,
211+
diff_text=diff_text,
212+
commit_shas=commit_shas,
213+
assessment_ids=assessment_ids,
214+
limit=display_limit,
215+
include_contradicted=False,
216+
)
217+
for d in ranked:
218+
if d["id"] not in seen_ids:
219+
full = get_decision(conn, d["id"])
205220
if full:
221+
full["score"] = d.get("score")
222+
full["score_breakdown"] = d.get("score_breakdown")
206223
file_related.append(full)
207-
seen_ids.add(effective_id)
208-
if len(seen_ids) >= display_limit:
209-
break
224+
seen_ids.add(d["id"])
210225

211226
# 2. Stale decisions — explicit status filter; separate from default policy.
212227
stale = list_decisions(conn, staleness_status="stale", limit=10)

tests/test_decision_hooks.py

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def test_max_5_decisions(self, ec_repo, ec_db, monkeypatch):
213213
entries = [line for line in result.split("\n") if line.strip().startswith("- [")]
214214
assert len(entries) <= 5
215215

216-
def test_session_start_stops_querying_changed_files_after_display_cap(self, ec_repo, ec_db, monkeypatch):
216+
def test_session_start_ranker_respects_display_limit(self, ec_repo, ec_db, monkeypatch):
217217
monkeypatch.setattr(
218218
"entirecontext.hooks.decision_hooks._load_decisions_config",
219219
lambda _: {"show_related_on_start": True},
@@ -226,37 +226,25 @@ def test_session_start_stops_querying_changed_files_after_display_cap(self, ec_r
226226

227227
monkeypatch.setattr("entirecontext.hooks.decision_hooks._get_recently_changed_files", lambda _: changed_files)
228228

229-
from entirecontext.core.decisions import list_decisions as core_list_decisions
230-
231-
file_path_calls: list[str] = []
232-
233-
def spy_list_decisions(
234-
conn,
235-
staleness_status=None,
236-
file_path=None,
237-
limit=20,
238-
include_contradicted=False,
239-
):
240-
if file_path is not None:
241-
file_path_calls.append(file_path)
242-
return core_list_decisions(
243-
conn,
244-
staleness_status=staleness_status,
245-
file_path=file_path,
246-
limit=limit,
247-
include_contradicted=include_contradicted,
248-
)
229+
from entirecontext.core.decisions import rank_related_decisions as core_ranker
230+
231+
ranker_calls: list[dict] = []
249232

250-
monkeypatch.setattr("entirecontext.core.decisions.list_decisions", spy_list_decisions)
233+
def spy_ranker(conn, **kwargs):
234+
ranker_calls.append(kwargs)
235+
return core_ranker(conn, **kwargs)
236+
237+
monkeypatch.setattr("entirecontext.core.decisions.rank_related_decisions", spy_ranker)
251238

252239
from entirecontext.hooks.decision_hooks import on_session_start_decisions
253240

254241
result = on_session_start_decisions({"cwd": str(ec_repo), "session_id": "s1"})
255242

256243
assert result is not None
257-
assert file_path_calls == changed_files[:5]
244+
assert len(ranker_calls) == 1
245+
assert ranker_calls[0]["limit"] == 5
258246
entries = [line for line in result.split("\n") if line.strip().startswith("- [")]
259-
assert len(entries) == 5
247+
assert len(entries) <= 5
260248

261249
def test_git_failure_returns_none(self, ec_repo, ec_db, monkeypatch):
262250
from unittest.mock import MagicMock
@@ -306,10 +294,10 @@ def test_session_start_excludes_contradicted_decisions(self, ec_repo, ec_db, mon
306294
assert "Contradicted choice" not in result
307295

308296
def test_session_start_hot_file_with_many_contradicted_still_surfaces_fresh(self, ec_repo, ec_db, monkeypatch):
309-
"""PR #55 Codex review: when a hot file has more than list_decisions'
310-
row cap of contradicted entries, a fresh decision just beyond that
311-
cap must still surface — the filter has to push down into SQL so the
312-
10-row cap can't hide valid guidance.
297+
"""When a hot file has many contradicted decisions, a fresh decision
298+
must still surface — rank_related_decisions excludes contradicted
299+
entries via include_contradicted=False so they cannot suppress valid
300+
guidance.
313301
"""
314302
from entirecontext.core.decisions import update_decision_staleness
315303

0 commit comments

Comments
 (0)