trigger-tree 1.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,937 @@
1
+ #!/usr/bin/env python3
2
+ """trigger-tree aggregator.
3
+
4
+ Combines $PROJECT/.trigger-tree/history*.jsonl (current file + rotated archives) with
5
+ an inventory of the documentation tree and prints a stats JSON to stdout.
6
+ Deterministic: all counting happens here so /tt only interprets, never computes.
7
+
8
+ Maturity model: files with zero reads are "untouched". They remain review signals,
9
+ never removal verdicts: cold-start → too early to judge; warming → early signal;
10
+ mature → enough history to review purpose, protection, and routing.
11
+
12
+ Config: $PROJECT/.trigger-tree/config.sh overrides the plugin default tt-config.sh.
13
+ Usage: python3 tt-stats.py [path/to/history.jsonl]
14
+ """
15
+
16
+ import argparse
17
+ import glob
18
+ import hashlib
19
+ import json
20
+ import math
21
+ import os
22
+ import re
23
+ import stat
24
+ import sys
25
+ import tempfile
26
+ from collections import Counter, defaultdict
27
+ from datetime import datetime, timezone
28
+ from fnmatch import fnmatch
29
+ from itertools import combinations
30
+ from statistics import median
31
+
32
+ ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
33
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
34
+
35
+ MATURITY_MIN_READS = 30 # below this (or MIN_SESSIONS): cold-start
36
+ MATURITY_MIN_SESSIONS = 3
37
+ MATURE_MIN_READS = 100 # below this (or MATURE_MIN_DAYS): warming
38
+ MATURE_MIN_DAYS = 7
39
+ TREND_DAILY_MAX_DAYS = 14 # daily buckets up to here, weekly beyond
40
+ CLUSTER_JACCARD = 0.6 # min similarity to join an existing task cluster
41
+ HIGH_IN_LINK_COUNT = 3
42
+ SCHEMA_VERSION = 1
43
+ HEAT_HALF_LIFE_DAYS = 30.0
44
+ HEAT_WINDOWS_DAYS = (7, 30, 90)
45
+ ROUTER_NAMES = ("README.md", "_index.md", "index.md", "CLAUDE.md")
46
+ TREND_MIN_EVENTS = 10
47
+ MAX_CO_READ_PATHS = 200
48
+ EVENT_TYPES = {"session", "prompt", "read", "scan", "skill", "note", "outcome"}
49
+
50
+
51
+ def _conf_texts():
52
+ # Layered: project override → plugin default. Broken entries never crash.
53
+ texts = []
54
+ for path in (
55
+ os.path.join(ROOT, ".trigger-tree", "config.sh"),
56
+ os.path.join(SCRIPT_DIR, "tt-config.sh"),
57
+ ):
58
+ try:
59
+ texts.append(open(path, encoding="utf-8").read())
60
+ except OSError:
61
+ continue
62
+ return texts
63
+
64
+
65
+ def _conf_regex(name, fallback):
66
+ for text in _conf_texts():
67
+ m = re.search(name + r"='([^']+)'", text)
68
+ if m:
69
+ try:
70
+ return re.compile(m.group(1))
71
+ except re.error:
72
+ continue
73
+ return re.compile(fallback)
74
+
75
+
76
+ def _conf_value(name, fallback=""):
77
+ for text in _conf_texts():
78
+ match = re.search(name + r"='([^']*)'", text)
79
+ if match:
80
+ return match.group(1)
81
+ return fallback
82
+
83
+
84
+ WATCH = _conf_regex(
85
+ "TT_WATCH_REGEX",
86
+ r"^(docs|agents|skills|agent-briefs)/.*\.md$|^\.claude/(rules|skills)/.*\.md$|"
87
+ r"^(CLAUDE|AGENTS|GEMINI)\.md$",
88
+ )
89
+ ALWAYS = _conf_regex("TT_ALWAYS_LOADED_REGEX", r"(^|/)(CLAUDE|AGENTS|GEMINI)\.md$")
90
+ CRITICAL_GLOBS = [
91
+ value.strip() for value in _conf_value("TT_CRITICAL_GLOB").split(",") if value.strip()
92
+ ]
93
+ EXPERIMENTAL_OUTCOMES = _conf_value("TT_EXPERIMENTAL_OUTCOMES", "off") == "on"
94
+
95
+ INVENTORY_BASES = [
96
+ "docs",
97
+ "agents",
98
+ "skills",
99
+ "agent-briefs",
100
+ ".claude/rules",
101
+ ".claude/skills",
102
+ ".",
103
+ ]
104
+
105
+
106
+ def inventory():
107
+ seen = set()
108
+ for base in INVENTORY_BASES:
109
+ top = os.path.join(ROOT, base)
110
+ if not os.path.isdir(top):
111
+ continue
112
+ walker = os.walk(top) if base != "." else [(top, [], os.listdir(top))]
113
+ for dirpath, _, files in walker:
114
+ for f in files:
115
+ full_path = os.path.join(dirpath, f)
116
+ rel = os.path.relpath(full_path, ROOT).replace(os.sep, "/")
117
+ if WATCH.search(rel) and safe_regular_file(full_path):
118
+ seen.add(rel)
119
+ return sorted(seen)
120
+
121
+
122
+ def safe_regular_file(path):
123
+ """Accept only real regular files; never follow project-controlled symlinks."""
124
+ try:
125
+ mode = os.lstat(path).st_mode
126
+ except OSError:
127
+ return False
128
+ return stat.S_ISREG(mode)
129
+
130
+
131
+ def history_files(explicit=None):
132
+ if explicit:
133
+ return [explicit]
134
+ # Archives sort before history.jsonl ("-" < "."), oldest first: chronological order.
135
+ return sorted(glob.glob(os.path.join(ROOT, ".trigger-tree", "history*.jsonl")))
136
+
137
+
138
+ def valid_event(event):
139
+ """Reject structurally incomplete telemetry before aggregation can crash."""
140
+ event_type = event.get("t")
141
+ if event_type not in EVENT_TYPES:
142
+ return False
143
+ if "ts" in event and not isinstance(event["ts"], str):
144
+ return False
145
+ required = (
146
+ "path" if event_type in ("read", "scan") else "skill" if event_type == "skill" else None
147
+ )
148
+ return required is None or isinstance(event.get(required), str) and bool(event[required])
149
+
150
+
151
+ def load_events_with_diagnostics(paths):
152
+ events = []
153
+ seen_tool_calls = set()
154
+ diagnostics = {"legacy_migrated": 0, "future_rejected": 0, "corrupt_lines": 0}
155
+ for path in paths:
156
+ if not safe_regular_file(path):
157
+ continue
158
+ with open(path, encoding="utf-8") as fh:
159
+ for line in fh:
160
+ line = line.strip()
161
+ if not line:
162
+ continue
163
+ try:
164
+ event = json.loads(line)
165
+ except json.JSONDecodeError:
166
+ diagnostics["corrupt_lines"] += 1
167
+ continue # a torn write should not kill the whole report
168
+ if not isinstance(event, dict):
169
+ diagnostics["corrupt_lines"] += 1
170
+ continue
171
+ version = event.get("schema_version", 0)
172
+ if version == 0:
173
+ event = {**event, "schema_version": SCHEMA_VERSION, "migrated_from": 0}
174
+ diagnostics["legacy_migrated"] += 1
175
+ elif version != SCHEMA_VERSION:
176
+ diagnostics["future_rejected"] += 1
177
+ continue
178
+ if not valid_event(event):
179
+ diagnostics["corrupt_lines"] += 1
180
+ continue
181
+ # Session resume/compaction can replay hook delivery around a boundary.
182
+ # Claude's tool_use_id is stable for that call, so count it once even
183
+ # when the duplicate straddles a rotated archive.
184
+ tool_use_id = event.get("tool_use_id")
185
+ if tool_use_id and event.get("t") in ("read", "scan", "skill"):
186
+ identity = (event.get("session"), event.get("t"), tool_use_id)
187
+ if identity in seen_tool_calls:
188
+ continue
189
+ seen_tool_calls.add(identity)
190
+ events.append(event)
191
+ return events, diagnostics
192
+
193
+
194
+ def load_events(paths):
195
+ return load_events_with_diagnostics(paths)[0]
196
+
197
+
198
+ IMPORT_RE = re.compile(r"(?<![\w`])@([^\s`]+)")
199
+
200
+
201
+ def claude_import_graph(docs):
202
+ """Return inventory files whose content is injected through CLAUDE.md imports.
203
+
204
+ Imports are resolved relative to the importing file, remain inside the project,
205
+ and are followed recursively with cycle protection. External/absolute imports
206
+ are intentionally ignored because they cannot be classified in this inventory.
207
+ """
208
+ doc_set = set(docs)
209
+ seeds = [p for p in docs if p == "CLAUDE.md" or p.endswith("/CLAUDE.md")]
210
+ loaded = set(seeds)
211
+ pending = list(seeds)
212
+ while pending:
213
+ source = pending.pop()
214
+ try:
215
+ text = open(os.path.join(ROOT, source), encoding="utf-8", errors="ignore").read()
216
+ except OSError:
217
+ continue
218
+ base = posix_dirname(source)
219
+ for raw in IMPORT_RE.findall(text):
220
+ target = raw.rstrip(".,;:)").replace("\\", "/")
221
+ if target.startswith(("/", "~", "http://", "https://")):
222
+ continue
223
+ candidate = os.path.normpath(os.path.join(base, target)).replace(os.sep, "/")
224
+ if candidate.startswith("../") or candidate not in doc_set or candidate in loaded:
225
+ continue
226
+ loaded.add(candidate)
227
+ pending.append(candidate)
228
+ return loaded
229
+
230
+
231
+ def posix_dirname(path):
232
+ return path.rsplit("/", 1)[0] if "/" in path else ""
233
+
234
+
235
+ def folder_routers(docs):
236
+ """Return the actual entry point for each folder, never an invented filename."""
237
+ available = set(docs)
238
+ folders = {posix_dirname(path) for path in docs}
239
+ routers = {}
240
+ for folder in folders:
241
+ for name in ROUTER_NAMES:
242
+ candidate = f"{folder}/{name}" if folder else name
243
+ if candidate in available:
244
+ routers[folder] = candidate
245
+ break
246
+ return routers
247
+
248
+
249
+ def text_mentions_target(text, target, source):
250
+ """Conservatively detect an existing local reference before proposing one."""
251
+ base = target.rsplit("/", 1)[-1]
252
+ relative = os.path.relpath(target, posix_dirname(source) or ".").replace(os.sep, "/")
253
+ return any(value and value in text for value in (target, relative, base))
254
+
255
+
256
+ def is_safety_path(path):
257
+ return path.startswith(".claude/rules/") or path.startswith("security/") or "/security/" in path
258
+
259
+
260
+ def is_critical_tagged(text):
261
+ return bool(
262
+ re.search(r"(?im)^\s*(?:critical\s*:\s*true|trigger-tree\s*:\s*critical)\s*$", text)
263
+ )
264
+
265
+
266
+ def protection_reasons(path, referenced_from, text, always_loaded):
267
+ reasons = []
268
+ if always_loaded:
269
+ reasons.append("always loaded into context")
270
+ if len(referenced_from) >= HIGH_IN_LINK_COUNT:
271
+ reasons.append(f"referenced by {len(referenced_from)} other docs")
272
+ if is_safety_path(path):
273
+ reasons.append("safety path")
274
+ matching_globs = [pattern for pattern in CRITICAL_GLOBS if fnmatch(path, pattern)]
275
+ if matching_globs:
276
+ reasons.append("critical glob " + ", ".join(matching_globs))
277
+ if is_critical_tagged(text):
278
+ reasons.append("tagged critical")
279
+ return reasons
280
+
281
+
282
+ def fingerprint(paths):
283
+ return hashlib.sha1("\n".join(sorted(paths)).encode()).hexdigest()[:10]
284
+
285
+
286
+ def parse_ts(ts):
287
+ try:
288
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")
289
+ except (TypeError, ValueError):
290
+ return None
291
+
292
+
293
+ def utc_now():
294
+ """Return a naive UTC datetime so tests can pin the heat reference clock."""
295
+ return datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0)
296
+
297
+
298
+ def temporal_metrics(timestamps, now):
299
+ """Separate current attention from lifetime reads.
300
+
301
+ Every timestamped read contributes exponentially decaying heat. Unknown-age
302
+ legacy reads remain in lifetime totals but cannot honestly contribute heat.
303
+ Future clock-skewed timestamps are treated as current rather than super-hot.
304
+ """
305
+ parsed = [dt for dt in (parse_ts(ts) for ts in timestamps) if dt]
306
+ ages = [max(0.0, (now - dt).total_seconds() / 86400) for dt in parsed]
307
+ result = {
308
+ "heat": round(sum(0.5 ** (age / HEAT_HALF_LIFE_DAYS) for age in ages), 3),
309
+ "heat_scored_reads": len(parsed),
310
+ }
311
+ result.update(
312
+ {f"reads_{days}d": sum(age <= days for age in ages) for days in HEAT_WINDOWS_DAYS}
313
+ )
314
+ return result
315
+
316
+
317
+ def observed_days(timestamps):
318
+ first, last = parse_ts(timestamps[0]), parse_ts(timestamps[-1])
319
+ if not first or not last:
320
+ return 0.0
321
+ return round((last - first).total_seconds() / 86400, 2)
322
+
323
+
324
+ def grade_for(score):
325
+ return (
326
+ "A"
327
+ if score >= 90
328
+ else "B" if score >= 75 else "C" if score >= 60 else "D" if score >= 45 else "F"
329
+ )
330
+
331
+
332
+ def badge_payload(health, maturity):
333
+ """Return a shields.io endpoint payload without publishing provisional grades."""
334
+ if maturity != "mature":
335
+ return {
336
+ "schemaVersion": 1,
337
+ "label": "docs health",
338
+ "message": "measuring…",
339
+ "color": "lightgrey",
340
+ }
341
+ colors = {"A": "brightgreen", "B": "green", "C": "yellow", "D": "orange", "F": "red"}
342
+ grade = health["grade"]
343
+ return {
344
+ "schemaVersion": 1,
345
+ "label": "docs health",
346
+ "message": f"{grade} ({health['score']})",
347
+ "color": colors[grade],
348
+ }
349
+
350
+
351
+ def write_badge(payload):
352
+ """Atomically write the endpoint JSON without following project-controlled links."""
353
+ directory = os.path.join(ROOT, ".trigger-tree")
354
+ if os.path.lexists(directory):
355
+ mode = os.lstat(directory).st_mode
356
+ if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
357
+ raise RuntimeError("refusing non-directory or symlinked .trigger-tree")
358
+ else:
359
+ os.makedirs(directory, mode=0o700)
360
+ destination = os.path.join(directory, "badge.json")
361
+ fd, temporary = tempfile.mkstemp(prefix=".badge.", dir=directory, text=True)
362
+ try:
363
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
364
+ json.dump(payload, handle, ensure_ascii=False)
365
+ handle.write("\n")
366
+ os.chmod(temporary, 0o600)
367
+ os.replace(temporary, destination)
368
+ finally:
369
+ try:
370
+ os.unlink(temporary)
371
+ except OSError:
372
+ pass
373
+ return destination
374
+
375
+
376
+ def jaccard(a, b):
377
+ a, b = set(a), set(b)
378
+ return len(a & b) / len(a | b) if a | b else 0.0
379
+
380
+
381
+ def detect_client(explicit="auto"):
382
+ if explicit in ("claude", "codex"):
383
+ return explicit
384
+ if os.environ.get("CODEX_HOME") or os.environ.get("PLUGIN_ROOT"):
385
+ return "codex"
386
+ if os.environ.get("CLAUDE_PLUGIN_ROOT"):
387
+ return "claude"
388
+ return None
389
+
390
+
391
+ def prompt_preview(event, client, fallback=""):
392
+ """Return task-shaped user text, never a client/harness envelope or fingerprint."""
393
+ prompt = (event.get("prompt") or "").strip()
394
+ if not prompt or event.get("prompt_hash") or prompt.startswith("#"):
395
+ return fallback
396
+ lowered = prompt.lower()
397
+ client_envelopes = {
398
+ "claude": ("<task-notification", "<agent-message", "<system-reminder"),
399
+ "codex": ("<environment_context", "<turn_aborted", "<collaboration"),
400
+ }
401
+ if any(lowered.startswith(prefix) for prefix in client_envelopes.get(client, ())):
402
+ return fallback
403
+ if re.fullmatch(r"/(?:trigger-tree:)?tt(?:\s+\S+)*", prompt, flags=re.I):
404
+ return fallback
405
+ if re.fullmatch(r"git\s+(?:status|diff|log)(?:\s+[-\w./]+)*", prompt, flags=re.I):
406
+ return fallback
407
+ return prompt
408
+
409
+
410
+ def main():
411
+ parser = argparse.ArgumentParser(add_help=False)
412
+ parser.add_argument("history", nargs="?")
413
+ parser.add_argument("--client", choices=("auto", "claude", "codex"), default="auto")
414
+ parser.add_argument("--badge", action="store_true")
415
+ args = parser.parse_args()
416
+ client = detect_client(args.client)
417
+ events, history_diagnostics = load_events_with_diagnostics(history_files(args.history))
418
+ docs = inventory()
419
+
420
+ reads = [e for e in events if e.get("t") == "read"]
421
+ scans = [e for e in events if e.get("t") == "scan"]
422
+ skill_events = [e for e in events if e.get("t") == "skill"]
423
+ outcome_events = [e for e in events if e.get("t") == "outcome"]
424
+ notes = [{"ts": e.get("ts"), "text": e.get("text", "")} for e in events if e.get("t") == "note"]
425
+ sessions = sorted({e.get("session", "?") for e in events})
426
+
427
+ heat_as_of = utc_now()
428
+ per_file = defaultdict(
429
+ lambda: {
430
+ "reads": 0,
431
+ "last_read": None,
432
+ "read_times": [],
433
+ "sessions": set(),
434
+ "agents": Counter(),
435
+ }
436
+ )
437
+ for e in reads:
438
+ f = per_file[e["path"]]
439
+ f["reads"] += 1
440
+ f["last_read"] = max(filter(None, [f["last_read"], e.get("ts")]), default=None)
441
+ f["read_times"].append(e.get("ts"))
442
+ f["sessions"].add(e.get("session", "?"))
443
+ f["agents"][e.get("agent", "main")] += 1
444
+
445
+ for data in per_file.values():
446
+ data.update(temporal_metrics(data["read_times"], heat_as_of))
447
+
448
+ scan_targets = Counter(e["path"] for e in scans)
449
+ scans_by_target_session = defaultdict(Counter)
450
+ scan_tools = defaultdict(Counter)
451
+ for event in scans:
452
+ target = event["path"]
453
+ scans_by_target_session[target][event.get("session", "?")] += 1
454
+ scan_tools[target][event.get("tool", "unknown")] += 1
455
+ search_activity = []
456
+ for path, count in scan_targets.most_common(10):
457
+ session_count = len(scans_by_target_session[path])
458
+ max_session_share = max(scans_by_target_session[path].values(), default=0) / count
459
+ pattern = (
460
+ "concentrated"
461
+ if session_count <= max(2, math.ceil(max(1, len(sessions)) * 0.2))
462
+ or max_session_share >= 0.6
463
+ else "distributed"
464
+ )
465
+ search_activity.append(
466
+ {
467
+ "path": path,
468
+ "scans": count,
469
+ "sessions": session_count,
470
+ "total_sessions": len(sessions),
471
+ "session_reach": round(session_count / max(1, len(sessions)), 2),
472
+ "max_session_share": round(max_session_share, 2),
473
+ "tools": dict(scan_tools[path]),
474
+ "pattern": pattern,
475
+ }
476
+ )
477
+
478
+ per_skill = defaultdict(lambda: {"uses": 0, "sessions": set(), "last_used": None})
479
+ for e in skill_events:
480
+ s = per_skill[e["skill"]]
481
+ s["uses"] += 1
482
+ s["sessions"].add(e.get("session", "?"))
483
+ s["last_used"] = max(filter(None, [s["last_used"], e.get("ts")]), default=None)
484
+
485
+ # Fingerprints: the set of doc paths (and skills used) between two prompt markers.
486
+ buckets = []
487
+ current = {} # session -> {"prompt": str, "paths": set}
488
+ last_real_prompt = {}
489
+ for e in events:
490
+ s = e.get("session", "?")
491
+ if e.get("t") == "prompt":
492
+ if s in current and current[s]["paths"]:
493
+ buckets.append(current[s])
494
+ preview = prompt_preview(e, client, last_real_prompt.get(s, ""))
495
+ if preview and preview != last_real_prompt.get(s):
496
+ last_real_prompt[s] = preview
497
+ current[s] = {"prompt": preview, "paths": set()}
498
+ elif e.get("t") == "read":
499
+ current.setdefault(s, {"prompt": "", "paths": set()})
500
+ current[s]["paths"].add(e["path"])
501
+ elif e.get("t") == "skill":
502
+ current.setdefault(s, {"prompt": "", "paths": set()})
503
+ current[s]["paths"].add(f"skill:{e['skill']}")
504
+ buckets.extend(b for b in current.values() if b["paths"])
505
+
506
+ fp_groups = defaultdict(lambda: {"count": 0, "prompts": [], "paths": []})
507
+ for b in buckets:
508
+ fp = fingerprint(b["paths"])
509
+ g = fp_groups[fp]
510
+ g["count"] += 1
511
+ g["paths"] = sorted(b["paths"])
512
+ if b["prompt"] and len(g["prompts"]) < 3:
513
+ g["prompts"].append(b["prompt"][:120])
514
+
515
+ # Task clusters: greedy Jaccard grouping of fingerprints — near-identical doc sets
516
+ # (e.g. UX-ish tasks with one file of variance) land in the same cluster.
517
+ clusters = []
518
+ for g in sorted(fp_groups.values(), key=lambda g: -g["count"]):
519
+ for c in clusters:
520
+ if jaccard(g["paths"], c["paths"]) >= CLUSTER_JACCARD:
521
+ c["count"] += g["count"]
522
+ c["variants"] += 1
523
+ c["prompts"] = (c["prompts"] + g["prompts"])[:3]
524
+ break
525
+ else:
526
+ clusters.append(
527
+ {
528
+ "paths": g["paths"],
529
+ "count": g["count"],
530
+ "variants": 1,
531
+ "prompts": list(g["prompts"]),
532
+ }
533
+ )
534
+ clusters.sort(key=lambda c: -c["count"])
535
+
536
+ co_read = Counter()
537
+ co_read_skipped_buckets = 0
538
+ for b in buckets:
539
+ paths = sorted(b["paths"])
540
+ if len(paths) > MAX_CO_READ_PATHS:
541
+ co_read_skipped_buckets += 1
542
+ continue
543
+ for a, c in combinations(paths, 2):
544
+ co_read[(a, c)] += 1
545
+
546
+ read_paths = set(per_file)
547
+ timestamps = sorted(filter(None, (e.get("ts") for e in events)))
548
+ days = observed_days(timestamps) if len(timestamps) >= 2 else 0.0
549
+
550
+ if len(reads) < MATURITY_MIN_READS or len(sessions) < MATURITY_MIN_SESSIONS:
551
+ maturity = "cold-start"
552
+ elif len(reads) < MATURE_MIN_READS or days < MATURE_MIN_DAYS:
553
+ maturity = "warming"
554
+ else:
555
+ maturity = "mature"
556
+
557
+ # Trend: daily buckets for short periods, ISO-week buckets beyond. The hunting
558
+ # ratio per bucket shows whether router changes (see `notes`) actually help.
559
+ daily = days <= TREND_DAILY_MAX_DAYS
560
+ trend_buckets = defaultdict(lambda: {"reads": 0, "scans": 0})
561
+ for e in reads + scans:
562
+ dt = parse_ts(e.get("ts"))
563
+ if not dt:
564
+ continue
565
+ if daily:
566
+ key = dt.strftime("%Y-%m-%d")
567
+ else:
568
+ iso = dt.isocalendar()
569
+ key = f"{iso[0]}-W{iso[1]:02d}"
570
+ trend_buckets[key][e["t"] + "s"] += 1
571
+ trend = [
572
+ {
573
+ "period": k,
574
+ **v,
575
+ "sample_size": v["reads"] + v["scans"],
576
+ "small_n": v["reads"] + v["scans"] < TREND_MIN_EVENTS,
577
+ "search_ratio": round(v["scans"] / v["reads"], 2) if v["reads"] else None,
578
+ "hunting_ratio": round(v["scans"] / v["reads"], 2) if v["reads"] else None,
579
+ }
580
+ for k, v in sorted(trend_buckets.items())
581
+ ]
582
+
583
+ # Always-loaded files (system-prompt injection / Skill tool) can never be "dead":
584
+ # their usage is invisible to Read-tool telemetry by design. A SKILL.md whose skill
585
+ # was actually invoked counts as touched via the Skill-tool events.
586
+ used_skill_files = {
587
+ f".claude/skills/{name}/SKILL.md"
588
+ for name in per_skill
589
+ if f".claude/skills/{name}/SKILL.md" in set(docs)
590
+ }
591
+ imported_files = claude_import_graph(docs)
592
+ always_loaded_set = {p for p in docs if ALWAYS.search(p)} | imported_files
593
+ doc_set = set(docs)
594
+ evaluable_set = doc_set - always_loaded_set
595
+ unread = [p for p in docs if p not in read_paths and p not in used_skill_files]
596
+ untouched = [p for p in unread if p not in always_loaded_set]
597
+ always_loaded_unread = [p for p in unread if p in always_loaded_set]
598
+
599
+ # Router-gap detection: an untouched file that no other doc links to is invisible
600
+ # to the router; an untouched file that IS referenced points at content/naming.
601
+ texts = {}
602
+ for p in docs:
603
+ try:
604
+ texts[p] = open(os.path.join(ROOT, p), encoding="utf-8", errors="ignore").read()
605
+ except OSError:
606
+ texts[p] = ""
607
+ routers = folder_routers(docs)
608
+ all_refs_by_path = {
609
+ p: sorted(
610
+ q for q, text in texts.items() if q != p and (p in text or p.rsplit("/", 1)[-1] in text)
611
+ )
612
+ for p in docs
613
+ }
614
+ router_coverage = []
615
+ for folder, router in sorted(routers.items()):
616
+ members = [
617
+ path
618
+ for path in docs
619
+ if posix_dirname(path) == folder and path != router and path not in always_loaded_set
620
+ ]
621
+ listed = [path for path in members if text_mentions_target(texts[router], path, router)]
622
+ router_coverage.append(
623
+ {
624
+ "folder": folder or "(root)",
625
+ "router": router,
626
+ "files": len(members),
627
+ "listed": len(listed),
628
+ "unlisted": sorted(set(members) - set(listed)),
629
+ }
630
+ )
631
+ untouched_detail = []
632
+ for p in untouched:
633
+ base = p.rsplit("/", 1)[-1]
634
+ refs = all_refs_by_path[p]
635
+ router = routers.get(posix_dirname(p))
636
+ is_router = p == router
637
+ untouched_detail.append(
638
+ {
639
+ "path": p,
640
+ "referenced_from": refs,
641
+ "referenced_from_sample": refs[:5],
642
+ "inbound_refs": len(refs),
643
+ "template": base.startswith("_") and not is_router,
644
+ "is_router": is_router,
645
+ "router": router,
646
+ "router_mentions_target": is_router
647
+ or bool(router and text_mentions_target(texts[router], p, router)),
648
+ }
649
+ )
650
+
651
+ review_candidates = []
652
+ for detail in untouched_detail:
653
+ p = detail["path"]
654
+ all_refs = all_refs_by_path[p]
655
+ reasons = protection_reasons(p, all_refs, texts.get(p, ""), False)
656
+ if detail["template"]:
657
+ reasons.append("template or intentional archive")
658
+ protected = bool(reasons)
659
+ why = (
660
+ reasons
661
+ if protected
662
+ else ["no reads in the measurement period", "not protected by critical-context rules"]
663
+ )
664
+ review_candidates.append(
665
+ {
666
+ **detail,
667
+ "classification": "protected" if protected else "review_candidate",
668
+ "why": why,
669
+ "recommendation": (
670
+ "review, likely keep — rare-but-critical"
671
+ if protected
672
+ else "review context and routing"
673
+ ),
674
+ "caveat": "Low reads can mean rare-but-critical; verify purpose and owners before archiving.",
675
+ }
676
+ )
677
+
678
+ protected_docs = []
679
+ for p in sorted(always_loaded_set):
680
+ protected_docs.append(
681
+ {
682
+ "path": p,
683
+ "classification": "protected",
684
+ "why": protection_reasons(p, [], texts.get(p, ""), True),
685
+ "recommendation": "keep — always loaded",
686
+ }
687
+ )
688
+
689
+ experimental_outcomes = None
690
+ if EXPERIMENTAL_OUTCOMES:
691
+ latest_outcome = {event.get("session", "?"): event for event in outcome_events}
692
+ buckets_by_outcome = {
693
+ "committed": {"sessions": set(), "reads": Counter()},
694
+ "abandoned": {"sessions": set(), "reads": Counter()},
695
+ }
696
+ for event in reads:
697
+ session = event.get("session", "?")
698
+ outcome = latest_outcome.get(session)
699
+ if not outcome:
700
+ continue
701
+ bucket = "committed" if outcome.get("git_commit_landed") else "abandoned"
702
+ buckets_by_outcome[bucket]["sessions"].add(session)
703
+ buckets_by_outcome[bucket]["reads"][event["path"]] += 1
704
+ experimental_outcomes = {"label": "experimental correlation — not causal"}
705
+ experimental_outcomes.update(
706
+ {
707
+ key: {
708
+ "sessions": len(value["sessions"]),
709
+ "docs": [
710
+ {"path": path, "reads": count}
711
+ for path, count in value["reads"].most_common(10)
712
+ ],
713
+ }
714
+ for key, value in buckets_by_outcome.items()
715
+ }
716
+ )
717
+
718
+ # Reconcile historical paths against the current inventory. Missing paths retain
719
+ # their evidence but cannot contribute to current coverage or current heat.
720
+ path_states = {}
721
+ for p in per_file:
722
+ exists = os.path.isfile(os.path.join(ROOT, p))
723
+ path_states[p] = (
724
+ "current" if p in doc_set and exists else "untracked" if exists else "retired"
725
+ )
726
+ retired_files = [
727
+ {
728
+ "path": p,
729
+ "reads": d["reads"],
730
+ "heat": d["heat"],
731
+ "last_read": d["last_read"],
732
+ "agents": dict(d["agents"]),
733
+ }
734
+ for p, d in sorted(per_file.items(), key=lambda item: (-item[1]["reads"], item[0]))
735
+ if path_states[p] == "retired"
736
+ ]
737
+
738
+ # Folder heat/cold map: current decayed attention plus measured churn.
739
+ folder_map = defaultdict(
740
+ lambda: {
741
+ "files": 0,
742
+ "touched": 0,
743
+ "reads": 0,
744
+ "heat": 0.0,
745
+ "reads_7d": 0,
746
+ "reads_30d": 0,
747
+ "reads_90d": 0,
748
+ "last_read": None,
749
+ "retired_reads": 0,
750
+ "agents": Counter(),
751
+ "file_ages_days": [],
752
+ }
753
+ )
754
+ touched_paths = (read_paths | used_skill_files) & evaluable_set
755
+ for p in docs:
756
+ folder = p.rsplit("/", 1)[0] if "/" in p else "(root)"
757
+ fm = folder_map[folder]
758
+ fm["files"] += 1
759
+ if p in touched_paths:
760
+ fm["touched"] += 1
761
+ if p in per_file:
762
+ data = per_file[p]
763
+ fm["reads"] += data["reads"]
764
+ fm["heat"] += data["heat"]
765
+ for window in HEAT_WINDOWS_DAYS:
766
+ fm[f"reads_{window}d"] += data[f"reads_{window}d"]
767
+ fm["last_read"] = max(filter(None, [fm["last_read"], data["last_read"]]), default=None)
768
+ fm["agents"].update(data["agents"])
769
+ try:
770
+ age = max(
771
+ 0.0, (heat_as_of.timestamp() - os.path.getmtime(os.path.join(ROOT, p))) / 86400
772
+ )
773
+ fm["file_ages_days"].append(age)
774
+ except OSError:
775
+ pass
776
+ for p, data in per_file.items():
777
+ if path_states[p] == "retired":
778
+ folder = p.rsplit("/", 1)[0] if "/" in p else "(root)"
779
+ folder_map[folder]["retired_reads"] += data["reads"]
780
+ folders_with_index = {
781
+ p.rsplit("/", 1)[0] if "/" in p else "(root)"
782
+ for p in docs
783
+ if p.rsplit("/", 1)[-1] in ROUTER_NAMES
784
+ }
785
+ folders = []
786
+ for key, value in sorted(folder_map.items()):
787
+ current_reads = value["reads"]
788
+ retired_reads = value.pop("retired_reads")
789
+ ages = value.pop("file_ages_days")
790
+ agents = dict(value.pop("agents"))
791
+ folders.append(
792
+ {
793
+ "folder": key,
794
+ **value,
795
+ "heat": round(value["heat"], 3),
796
+ "coverage": round(value["touched"] / value["files"], 2) if value["files"] else 0.0,
797
+ "has_index": key in folders_with_index,
798
+ "retired_reads": retired_reads,
799
+ "retired_read_share": (
800
+ round(retired_reads / (current_reads + retired_reads), 2)
801
+ if current_reads + retired_reads
802
+ else 0.0
803
+ ),
804
+ "median_file_age_days": round(median(ages), 2) if ages else None,
805
+ "agents": agents,
806
+ }
807
+ )
808
+
809
+ # Documentation health: one deterministic grade a product owner can track.
810
+ router_gaps = sum(1 for d in untouched_detail if not d["referenced_from"])
811
+ denom = max(1, len(evaluable_set))
812
+ coverage_overall = round(len(touched_paths) / denom, 2)
813
+ distributed_scans = sum(
814
+ item["scans"] for item in search_activity if item["pattern"] == "distributed"
815
+ )
816
+ distributed_search_ratio = round(distributed_scans / len(reads), 2) if reads else 0.0
817
+ score = max(
818
+ 0,
819
+ min(
820
+ 100,
821
+ round(
822
+ 100
823
+ - (1 - coverage_overall) * 40
824
+ - min(20, router_gaps * 4)
825
+ - min(20, distributed_search_ratio * 50)
826
+ ),
827
+ ),
828
+ )
829
+ health = {
830
+ "score": score,
831
+ "grade": grade_for(score),
832
+ "coverage": coverage_overall,
833
+ "drivers": [
834
+ f"{len(untouched)} of {len(evaluable_set)} evaluable docs untouched",
835
+ f"{router_gaps} router gaps (untouched and unreferenced)",
836
+ f"distributed search ratio {distributed_search_ratio}",
837
+ ],
838
+ }
839
+
840
+ out = {
841
+ "observed_from": timestamps[0] if timestamps else None,
842
+ "observed_to": timestamps[-1] if timestamps else None,
843
+ "observed_days": days,
844
+ "heat_model": {
845
+ "kind": "exponential_decay",
846
+ "half_life_days": HEAT_HALF_LIFE_DAYS,
847
+ "as_of": heat_as_of.strftime("%Y-%m-%dT%H:%M:%SZ"),
848
+ "windows_days": list(HEAT_WINDOWS_DAYS),
849
+ "untimestamped_reads": sum(
850
+ d["reads"] - d["heat_scored_reads"] for d in per_file.values()
851
+ ),
852
+ },
853
+ "sessions": len(sessions),
854
+ "maturity": maturity,
855
+ "health": health,
856
+ "totals": {
857
+ "events": len(events),
858
+ "reads": len(reads),
859
+ "scans": len(scans),
860
+ "skill_uses": len(skill_events),
861
+ "prompts_with_doc_reads": len(buckets),
862
+ "inventory_files": len(docs),
863
+ "evaluable_files": len(evaluable_set),
864
+ "always_loaded_files": len(always_loaded_set),
865
+ "touched_current_files": len(touched_paths),
866
+ "untouched_current_files": len(untouched),
867
+ },
868
+ "files": [
869
+ {
870
+ "path": p,
871
+ "reads": d["reads"],
872
+ "heat": d["heat"],
873
+ "reads_7d": d["reads_7d"],
874
+ "reads_30d": d["reads_30d"],
875
+ "reads_90d": d["reads_90d"],
876
+ "heat_scored_reads": d["heat_scored_reads"],
877
+ "last_read": d["last_read"],
878
+ "sessions": len(d["sessions"]),
879
+ "agents": dict(d["agents"]),
880
+ "state": path_states[p],
881
+ "main_reads": d["agents"].get("main", 0),
882
+ "subagent_reads": d["reads"] - d["agents"].get("main", 0),
883
+ }
884
+ for p, d in sorted(per_file.items(), key=lambda kv: -kv[1]["reads"])
885
+ ],
886
+ "skills": [
887
+ {
888
+ "name": n,
889
+ "uses": d["uses"],
890
+ "sessions": len(d["sessions"]),
891
+ "last_used": d["last_used"],
892
+ }
893
+ for n, d in sorted(per_skill.items(), key=lambda kv: -kv[1]["uses"])
894
+ ],
895
+ "untouched": untouched,
896
+ "untouched_detail": untouched_detail,
897
+ "review_candidates": review_candidates,
898
+ "review_caveat": "Low reads can mean rare-but-critical; verify purpose and owners before archiving.",
899
+ "router_coverage": router_coverage,
900
+ "unread_routers": sorted(d["path"] for d in untouched_detail if d["is_router"]),
901
+ "protected_docs": protected_docs,
902
+ "folders": folders,
903
+ "always_loaded": always_loaded_unread,
904
+ "always_loaded_inventory": sorted(always_loaded_set),
905
+ "always_loaded_unread": always_loaded_unread,
906
+ "always_loaded_imports": sorted(imported_files),
907
+ "signal_integrity": {
908
+ "subagent_reads": "captured",
909
+ "subagent_read_events": sum(1 for e in reads if e.get("agent_id")),
910
+ "compaction_boundaries": sum(
911
+ 1 for e in events if e.get("t") == "session" and e.get("source") == "compact"
912
+ ),
913
+ },
914
+ "history_schema": {"current": SCHEMA_VERSION, **history_diagnostics},
915
+ "experimental_outcomes": experimental_outcomes,
916
+ "retired_files": retired_files,
917
+ "unknown_reads": sorted(p for p in read_paths if path_states[p] == "untracked"),
918
+ "search_activity": search_activity,
919
+ "hunting": search_activity, # compatibility alias; scans are not causal evidence
920
+ "trend": trend,
921
+ "notes": notes,
922
+ "clusters": clusters[:12],
923
+ "co_read_top": [{"pair": list(pair), "count": n} for pair, n in co_read.most_common(15)],
924
+ "co_read_diagnostics": {
925
+ "max_paths_per_prompt": MAX_CO_READ_PATHS,
926
+ "oversized_prompts_skipped": co_read_skipped_buckets,
927
+ },
928
+ }
929
+ if args.badge:
930
+ print(write_badge(badge_payload(health, maturity)))
931
+ else:
932
+ json.dump(out, sys.stdout, indent=1)
933
+ print()
934
+
935
+
936
+ if __name__ == "__main__":
937
+ main()