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.
- trigger_tree/__init__.py +1 -0
- trigger_tree/_scripts/tt-codex-hook.py +120 -0
- trigger_tree/_scripts/tt-config.sh +32 -0
- trigger_tree/_scripts/tt-doctor.py +288 -0
- trigger_tree/_scripts/tt-log.py +656 -0
- trigger_tree/_scripts/tt-open.sh +164 -0
- trigger_tree/_scripts/tt-publish-badge.sh +26 -0
- trigger_tree/_scripts/tt-report.py +623 -0
- trigger_tree/_scripts/tt-setup.py +266 -0
- trigger_tree/_scripts/tt-shell-capture.sh +48 -0
- trigger_tree/_scripts/tt-stats.py +937 -0
- trigger_tree/_scripts/tt-statusline.py +154 -0
- trigger_tree/_scripts/tt-suggestions.py +128 -0
- trigger_tree/_scripts/tt-tips.py +107 -0
- trigger_tree/_scripts/tt-uninstall.py +60 -0
- trigger_tree/_scripts/tt-watch.py +1013 -0
- trigger_tree/_scripts/tt_runtime.py +27 -0
- trigger_tree/_scripts/tt_scope.py +76 -0
- trigger_tree/_scripts/validate_palette.js +28 -0
- trigger_tree/cli.py +52 -0
- trigger_tree/hooks/claude-hooks.json +11 -0
- trigger_tree/hooks/hooks.json +9 -0
- trigger_tree-1.14.0.dist-info/METADATA +106 -0
- trigger_tree-1.14.0.dist-info/RECORD +27 -0
- trigger_tree-1.14.0.dist-info/WHEEL +4 -0
- trigger_tree-1.14.0.dist-info/entry_points.txt +2 -0
- trigger_tree-1.14.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|