Skip to content

Commit f1c83e4

Browse files
feat(freshness): context freshness check before starting a session (#58)
Add agent-strace freshness — compares the current codebase state against what the agent last saw, using git diff between the last session timestamp and HEAD. agent-strace freshness agent-strace freshness --since 2026-04-01 --scope 'src/**' Reports: - Files changed since last session (or since --since date) - Per-file change type (modified/added/deleted/renamed) and line count - Freshness score 0-100 (100 = nothing changed since last session) - Estimated catch-up reading time for in-scope files Scope is auto-detected from CLAUDE.md / AGENTS.md scope sections, or overridden with --scope. Uses git rev-list + git diff --numstat; no API calls required. Closes #42 Co-authored-by: Ona <no-reply@ona.com>
1 parent 9191676 commit f1c83e4

3 files changed

Lines changed: 425 additions & 0 deletions

File tree

src/agent_trace/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .a2a import cmd_a2a_tree
2626
from .annotate import cmd_annotate
2727
from .oncall import cmd_oncall
28+
from .freshness import cmd_freshness
2829
from .audit import cmd_audit
2930
from .cost import cmd_cost
3031
from .curve import cmd_curve
@@ -634,6 +635,12 @@ def build_parser() -> argparse.ArgumentParser:
634635
p_oncall.add_argument("--since-days", type=int, default=30, dest="since_days",
635636
help="how many days of sessions to scan (default: 30)")
636637

638+
# freshness (context freshness check)
639+
p_fresh = sub.add_parser("freshness", help="check how stale the agent context is since last session")
640+
p_fresh.add_argument("--since", default="", help="check changes since this date (YYYY-MM-DD)")
641+
p_fresh.add_argument("--scope", default="", help="file glob to limit scope")
642+
p_fresh.add_argument("--repo", default=".", help="path to git repository (default: .)")
643+
637644
# diff --semantic and --eval-config flags (extend existing diff parser)
638645
p_diff.add_argument("--semantic", action="store_true",
639646
help="semantic outcome-level diff (files, cost, errors)")
@@ -689,6 +696,7 @@ def main() -> None:
689696
"inflation": cmd_inflation,
690697
"a2a-tree": cmd_a2a_tree,
691698
"oncall": cmd_oncall,
699+
"freshness": cmd_freshness,
692700
}
693701

694702
handler = handlers.get(args.command)

src/agent_trace/freshness.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
"""Context freshness check: flag stale context before a session starts.
2+
3+
Compares the current codebase state against what the agent last saw,
4+
using git diff between the last session timestamp and HEAD.
5+
6+
Usage:
7+
agent-strace freshness
8+
agent-strace freshness --since 2026-04-01
9+
agent-strace freshness --scope "src/**"
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import fnmatch
16+
import re
17+
import subprocess
18+
import sys
19+
import time
20+
from dataclasses import dataclass, field
21+
from datetime import datetime, timezone
22+
from pathlib import Path
23+
from typing import TextIO
24+
25+
from .store import TraceStore
26+
27+
28+
# ---------------------------------------------------------------------------
29+
# Data structures
30+
# ---------------------------------------------------------------------------
31+
32+
@dataclass
33+
class StaleFile:
34+
path: str
35+
lines_changed: int
36+
change_type: str # "modified" | "added" | "deleted" | "renamed"
37+
in_scope: bool # True if in CLAUDE.md / AGENTS.md scope
38+
39+
40+
@dataclass
41+
class FreshnessReport:
42+
last_session_ts: float | None
43+
last_session_id: str
44+
files_changed_total: int
45+
files_in_scope: int
46+
stale_files: list[StaleFile]
47+
freshness_score: int # 0–100 (100 = fully fresh)
48+
reading_minutes: float
49+
scope_source: str # "CLAUDE.md" | "AGENTS.md" | "--scope flag" | "all files"
50+
scope_glob: str
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Scope detection
55+
# ---------------------------------------------------------------------------
56+
57+
def _parse_scope_from_agents_md(path: str = "CLAUDE.md") -> list[str]:
58+
"""Extract file globs from CLAUDE.md / AGENTS.md scope sections."""
59+
globs: list[str] = []
60+
for fname in ("CLAUDE.md", "AGENTS.md", path):
61+
p = Path(fname)
62+
if not p.exists():
63+
continue
64+
text = p.read_text(errors="replace")
65+
# Look for lines that look like file globs after scope/files headers
66+
in_scope = False
67+
for line in text.splitlines():
68+
stripped = line.strip()
69+
if re.search(r"(scope|files|include|watch)", stripped, re.I) and stripped.endswith(":"):
70+
in_scope = True
71+
continue
72+
if in_scope:
73+
if stripped.startswith("-") or stripped.startswith("*"):
74+
glob = stripped.lstrip("- ").strip("`")
75+
if glob:
76+
globs.append(glob)
77+
elif stripped and not stripped.startswith("#"):
78+
in_scope = False
79+
if globs:
80+
return globs
81+
return []
82+
83+
84+
# ---------------------------------------------------------------------------
85+
# Git helpers
86+
# ---------------------------------------------------------------------------
87+
88+
def _git_diff_since(repo: str, since_ts: float) -> list[tuple[str, int, str]]:
89+
"""Return list of (path, lines_changed, change_type) since a timestamp."""
90+
since = datetime.fromtimestamp(since_ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
91+
try:
92+
# Get the commit hash at that time
93+
result = subprocess.run(
94+
["git", "-C", repo, "rev-list", "-1", f"--before={since}", "HEAD"],
95+
capture_output=True, text=True, timeout=15,
96+
)
97+
base_commit = result.stdout.strip()
98+
if not base_commit:
99+
return []
100+
101+
# Diff from that commit to HEAD
102+
diff_result = subprocess.run(
103+
["git", "-C", repo, "diff", "--numstat", base_commit, "HEAD"],
104+
capture_output=True, text=True, timeout=30,
105+
)
106+
files = []
107+
for line in diff_result.stdout.splitlines():
108+
parts = line.split("\t")
109+
if len(parts) < 3:
110+
continue
111+
added_str, removed_str, path = parts[0], parts[1], parts[2]
112+
try:
113+
lines = int(added_str) + int(removed_str)
114+
except ValueError:
115+
lines = 0
116+
# Detect rename: "old => new"
117+
if "=>" in path:
118+
change_type = "renamed"
119+
path = path.split("=>")[-1].strip().strip("}")
120+
elif added_str == "0" and removed_str != "0":
121+
change_type = "deleted"
122+
elif removed_str == "0" and added_str != "0":
123+
change_type = "added"
124+
else:
125+
change_type = "modified"
126+
files.append((path.strip(), lines, change_type))
127+
return files
128+
except Exception:
129+
return []
130+
131+
132+
def _git_diff_since_date(repo: str, since_date: str) -> list[tuple[str, int, str]]:
133+
"""Return changed files since a date string (e.g. '2026-04-01')."""
134+
try:
135+
result = subprocess.run(
136+
["git", "-C", repo, "rev-list", "-1", f"--before={since_date}", "HEAD"],
137+
capture_output=True, text=True, timeout=15,
138+
)
139+
base_commit = result.stdout.strip()
140+
if not base_commit:
141+
return []
142+
diff_result = subprocess.run(
143+
["git", "-C", repo, "diff", "--numstat", base_commit, "HEAD"],
144+
capture_output=True, text=True, timeout=30,
145+
)
146+
files = []
147+
for line in diff_result.stdout.splitlines():
148+
parts = line.split("\t")
149+
if len(parts) < 3:
150+
continue
151+
try:
152+
lines = int(parts[0]) + int(parts[1])
153+
except ValueError:
154+
lines = 0
155+
path = parts[2].strip()
156+
change_type = "modified"
157+
files.append((path, lines, change_type))
158+
return files
159+
except Exception:
160+
return []
161+
162+
163+
# ---------------------------------------------------------------------------
164+
# Analysis
165+
# ---------------------------------------------------------------------------
166+
167+
def analyse_freshness(
168+
store: TraceStore,
169+
since_date: str = "",
170+
scope_glob: str = "",
171+
repo: str = ".",
172+
) -> FreshnessReport:
173+
"""Compute context freshness relative to the last session."""
174+
# Find last session timestamp
175+
all_metas = store.list_sessions()
176+
last_meta = all_metas[-1] if all_metas else None
177+
last_ts = last_meta.started_at if last_meta else None
178+
last_sid = last_meta.session_id if last_meta else ""
179+
180+
# Determine scope
181+
scope_source = "all files"
182+
scope_globs: list[str] = []
183+
if scope_glob:
184+
scope_globs = [scope_glob]
185+
scope_source = "--scope flag"
186+
else:
187+
scope_globs = _parse_scope_from_agents_md()
188+
if scope_globs:
189+
scope_source = "CLAUDE.md / AGENTS.md"
190+
191+
# Get changed files
192+
if since_date:
193+
raw_files = _git_diff_since_date(repo, since_date)
194+
elif last_ts:
195+
raw_files = _git_diff_since(repo, last_ts)
196+
else:
197+
raw_files = []
198+
199+
# Build stale file list
200+
stale: list[StaleFile] = []
201+
for path, lines, change_type in raw_files:
202+
in_scope = True
203+
if scope_globs:
204+
in_scope = any(fnmatch.fnmatch(path, g) for g in scope_globs)
205+
stale.append(StaleFile(
206+
path=path,
207+
lines_changed=lines,
208+
change_type=change_type,
209+
in_scope=in_scope,
210+
))
211+
212+
# Sort: in-scope first, then by lines changed descending
213+
stale.sort(key=lambda f: (not f.in_scope, -f.lines_changed))
214+
215+
files_in_scope = sum(1 for f in stale if f.in_scope)
216+
total = len(stale)
217+
218+
# Freshness score: 100 if nothing changed, decreases with scope changes
219+
if total == 0:
220+
score = 100
221+
else:
222+
scope_weight = files_in_scope / max(total, 1)
223+
large_changes = sum(1 for f in stale if f.in_scope and f.lines_changed > 100)
224+
score = max(0, int(100 - scope_weight * 60 - large_changes * 10))
225+
226+
reading_minutes = sum(
227+
max(1.0, f.lines_changed / 200.0) for f in stale if f.in_scope
228+
)
229+
230+
return FreshnessReport(
231+
last_session_ts=last_ts,
232+
last_session_id=last_sid,
233+
files_changed_total=total,
234+
files_in_scope=files_in_scope,
235+
stale_files=stale,
236+
freshness_score=score,
237+
reading_minutes=reading_minutes,
238+
scope_source=scope_source,
239+
scope_glob=scope_glob or (", ".join(scope_globs[:3]) if scope_globs else "**"),
240+
)
241+
242+
243+
# ---------------------------------------------------------------------------
244+
# Formatting
245+
# ---------------------------------------------------------------------------
246+
247+
def format_freshness(report: FreshnessReport, out: TextIO = sys.stdout) -> None:
248+
w = out.write
249+
sep = "─" * 55
250+
251+
w(f"\nContext Freshness Report\n{sep}\n")
252+
253+
if report.last_session_ts:
254+
age_h = int((time.time() - report.last_session_ts) / 3600)
255+
age_str = f"{age_h}h ago" if age_h < 48 else f"{age_h // 24}d ago"
256+
w(f"Last session: {report.last_session_id[:12]} ({age_str})\n")
257+
else:
258+
w("Last session: none found\n")
259+
260+
w(f"Scope: {report.scope_glob} [{report.scope_source}]\n")
261+
w(f"Files changed: {report.files_changed_total} total, "
262+
f"{report.files_in_scope} in scope\n")
263+
264+
score = report.freshness_score
265+
bar_filled = score // 5
266+
bar = "█" * bar_filled + "░" * (20 - bar_filled)
267+
icon = "✅" if score >= 80 else ("⚠️ " if score >= 50 else "❌")
268+
w(f"Freshness score: {icon} {score}/100 [{bar}]\n")
269+
w(f"{sep}\n\n")
270+
271+
if not report.stale_files:
272+
w("✅ Context is fully fresh — no changes since last session.\n\n")
273+
return
274+
275+
in_scope = [f for f in report.stale_files if f.in_scope]
276+
out_of_scope = [f for f in report.stale_files if not f.in_scope]
277+
278+
if in_scope:
279+
w("Stale files in scope:\n\n")
280+
for f in in_scope[:15]:
281+
icon = "❌" if f.lines_changed > 200 else "⚠️ "
282+
w(f" {icon} {f.path}\n")
283+
w(f" {f.change_type} · {f.lines_changed} lines changed\n")
284+
if len(in_scope) > 15:
285+
w(f" ... and {len(in_scope) - 15} more\n")
286+
w("\n")
287+
288+
if out_of_scope:
289+
w(f"Out-of-scope changes: {len(out_of_scope)} files\n\n")
290+
291+
h = int(report.reading_minutes // 60)
292+
m = int(report.reading_minutes % 60)
293+
time_str = f"{h}h {m}min" if h else f"{int(m)}min"
294+
w(f"Estimated catch-up time: {time_str}\n")
295+
w(f"{sep}\n\n")
296+
297+
298+
# ---------------------------------------------------------------------------
299+
# CLI handler
300+
# ---------------------------------------------------------------------------
301+
302+
def cmd_freshness(args: argparse.Namespace) -> int:
303+
store = TraceStore(args.trace_dir)
304+
since = getattr(args, "since", "") or ""
305+
scope = getattr(args, "scope", "") or ""
306+
repo = getattr(args, "repo", ".") or "."
307+
308+
report = analyse_freshness(store, since_date=since, scope_glob=scope, repo=repo)
309+
format_freshness(report)
310+
return 0

0 commit comments

Comments
 (0)