f65ea89a24
gates / gates (push) Successful in 8s
install_workspace.py lays down the two things that shaped every session while existing on one host only. Unlike install_skills.py the targets are LIVE CONFIG, so: timestamped backup before every write, settings.json MERGED (this script owns exactly one key), a diverged CLAUDE.md reported rather than silently resolved, and an unparseable settings.json refused outright. Proven: all 7 top-level settings keys survived byte-identically, and run 2 wrote nothing. rules_report.py surfaces the column that matters -- rules that have NEVER fired, which are mis-globbed or dead. 6 of 9 on first run. The hook now self-rotates at 5 MB. The memory store is BACKED UP, NOT COMMITTED (auto-written, may name hosts/paths): added to dooplex-backup.service's User Data component. /opt/backup/scripts/ is itself unversioned host state -- filed, not fixed here.
135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Which instruction files actually load, how often — and which NEVER have.
|
|
|
|
Usage: python3 scripts/rules_report.py [--log FILE] [--workspace DIR] [--days N]
|
|
|
|
READS the InstructionsLoaded hook log (~/.claude/instructions-loaded.jsonl, plus its rotated .1) and
|
|
CROSS-REFERENCES it against every .claude/rules/*.md across the workspace.
|
|
|
|
THE COLUMN THAT MATTERS IS THE EMPTY ONE. A rule that has never fired is not a rule — it is either
|
|
mis-globbed (its `paths:` never match the files people actually edit) or dead (the surface it guards
|
|
is gone). That is the built-but-never-wired failure class, which this project has shipped four times,
|
|
applied to instructions: the rule file exists, reads correctly, passes the gate that checks it
|
|
declares `paths:` — and reaches the model never.
|
|
|
|
A never-fired rule is NOT automatically a defect. A rule scoped to a surface nobody has touched since
|
|
it was written is simply untested. The report says which, it does not judge: `paths:` globs are
|
|
printed beside each silent rule so the reader can tell "wrong glob" from "quiet month".
|
|
|
|
Exit code is always 0 — this is a report, not a gate.
|
|
"""
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
|
|
def load_events(paths, days):
|
|
import datetime
|
|
cutoff = None
|
|
if days:
|
|
cutoff = (datetime.datetime.utcnow() - datetime.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
events, bad = [], 0
|
|
for p in paths:
|
|
if not os.path.exists(p):
|
|
continue
|
|
with open(p, "r", encoding="utf-8", errors="replace") as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
e = json.loads(line)
|
|
except ValueError:
|
|
bad += 1
|
|
continue
|
|
if cutoff and (e.get("ts") or "") < cutoff:
|
|
continue
|
|
events.append(e)
|
|
return events, bad
|
|
|
|
|
|
def find_rule_files(workspace):
|
|
"""Every .claude/rules/*.md in the workspace, with its declared paths: globs."""
|
|
out = {}
|
|
for repo in sorted(os.listdir(workspace)):
|
|
rd = os.path.join(workspace, repo, ".claude", "rules")
|
|
if not os.path.isdir(rd):
|
|
continue
|
|
for name in sorted(os.listdir(rd)):
|
|
if not name.endswith(".md"):
|
|
continue
|
|
full = os.path.join(rd, name)
|
|
try:
|
|
with open(full, "r", encoding="utf-8") as fh:
|
|
head = fh.read(2048)
|
|
except OSError:
|
|
head = ""
|
|
m = re.search(r"^paths:\s*(.+)$", head, re.M)
|
|
globs = m.group(1).strip() if m else ("(unconditional)" if re.search(
|
|
r"^unconditional:\s*true", head, re.M) else "(NO paths: — loads every session)")
|
|
out[full] = (repo, name, globs)
|
|
return out
|
|
|
|
|
|
def main(argv):
|
|
home = os.path.expanduser("~")
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--log", default=os.path.join(home, ".claude", "instructions-loaded.jsonl"))
|
|
ap.add_argument("--workspace",
|
|
default=os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
ap.add_argument("--days", type=int, default=0, help="only count events newer than N days")
|
|
args = ap.parse_args(argv[1:])
|
|
|
|
logs = [args.log, args.log + ".1"]
|
|
events, bad = load_events(logs, args.days)
|
|
|
|
print("rules_report")
|
|
print(" log : %s%s" % (args.log, " (+ rotated .1)" if os.path.exists(args.log + ".1") else ""))
|
|
print(" workspace : %s" % args.workspace)
|
|
print(" events : %d%s%s" % (len(events),
|
|
(" (last %d days)" % args.days) if args.days else "",
|
|
(" [%d unparseable lines skipped]" % bad) if bad else ""))
|
|
if not events:
|
|
print("\n No events. The hook may not be installed — see scripts/install_workspace.py.")
|
|
|
|
by_reason = collections.Counter(e.get("load_reason") or "?" for e in events)
|
|
print("\n== why instructions loaded ==")
|
|
for reason, n in by_reason.most_common():
|
|
print(" %-18s %d" % (reason, n))
|
|
|
|
fired = collections.Counter()
|
|
triggers = collections.defaultdict(collections.Counter)
|
|
for e in events:
|
|
fp = e.get("file_path") or "?"
|
|
fired[fp] += 1
|
|
t = e.get("trigger_file_path")
|
|
if t:
|
|
triggers[fp][os.path.basename(t)] += 1
|
|
|
|
print("\n== instruction files that LOADED ==")
|
|
if not fired:
|
|
print(" (none)")
|
|
for fp, n in fired.most_common():
|
|
short = fp.replace(args.workspace + os.sep, "")
|
|
ex = triggers[fp].most_common(1)
|
|
print(" %5d %-58s %s" % (n, short, ("e.g. via %s" % ex[0][0]) if ex else ""))
|
|
|
|
rules = find_rule_files(args.workspace)
|
|
silent = [(p, v) for p, v in sorted(rules.items()) if p not in fired]
|
|
print("\n== rule files that have NEVER fired (%d of %d) ==" % (len(silent), len(rules)))
|
|
if not silent:
|
|
print(" none — every rule file in the workspace has loaded at least once")
|
|
for p, (repo, name, globs) in silent:
|
|
print(" %-24s %-18s paths: %s" % (repo, name, globs))
|
|
if silent:
|
|
print("\n A never-fired rule is mis-globbed, dead, or simply guarding a surface nobody has")
|
|
print(" edited since it was written. The globs above are printed so you can tell which.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|