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,154 @@
1
+ #!/usr/bin/env python3
2
+ """trigger-tree statusline — live doc-discovery stats for the current session.
3
+
4
+ Portable (macOS/Linux), stdlib only. The dot pulses with the age of the last read or scan:
5
+ ● bright green < 90s, ◐ amber < 10min, ○ dim otherwise.
6
+ Register in project or user settings under "statusLine" with a refreshInterval.
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ import time
15
+ import unicodedata
16
+ from datetime import datetime, timezone
17
+
18
+ ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
19
+ HIST = os.path.join(ROOT, ".trigger-tree", "history.jsonl")
20
+ BADGE = os.path.join(ROOT, ".trigger-tree", "badge.json")
21
+
22
+ RESET = "\033[0m"
23
+ FRESH = "\033[1;38;5;114m" # bright green
24
+ WARM = "\033[38;5;178m" # amber
25
+ COLD = "\033[38;5;245m" # dim
26
+
27
+
28
+ def terminal_safe(value):
29
+ """Strip terminal controls and invisible direction overrides from untrusted text."""
30
+ bidi_controls = "\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069"
31
+ text = re.sub(r"(?:\x1b\]|\x9d).*?(?:\x07|\x1b\\|\x9c)", "", str(value), flags=re.S)
32
+ text = re.sub(r"(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]", "", text)
33
+ text = re.sub(r"\x1b[@-_]", "", text)
34
+ return "".join(
35
+ ch
36
+ for ch in text
37
+ if unicodedata.category(ch) not in ("Cc", "Cs") and ch not in bidi_controls
38
+ )
39
+
40
+
41
+ try:
42
+ sys.stdout.reconfigure(encoding="utf-8") # emoji-safe on Windows consoles
43
+ except AttributeError: # pragma: no cover — exotic stdout replacement
44
+ pass
45
+
46
+
47
+ def main():
48
+ try:
49
+ data = json.load(sys.stdin)
50
+ except (json.JSONDecodeError, ValueError):
51
+ data = {}
52
+ session = data.get("session_id")
53
+ if not session:
54
+ print("🌳 tt: no data")
55
+ return
56
+
57
+ files, scans, last, last_time = {}, 0, None, None
58
+ state_name = hashlib.sha256(str(session).encode("utf-8")).hexdigest()[:32] + ".json"
59
+ state_path = os.path.join(ROOT, ".trigger-tree", "sessions", state_name)
60
+ try:
61
+ state = json.loads(open(state_path, encoding="utf-8").read())
62
+ except (OSError, ValueError):
63
+ state = None
64
+ if state is not None:
65
+ files = {path: True for path in state.get("files", [])}
66
+ scans = int(state.get("scans", 0))
67
+ last = state.get("last")
68
+ try:
69
+ last_time = datetime.strptime(last["ts"], "%Y-%m-%dT%H:%M:%SZ").replace(
70
+ tzinfo=timezone.utc
71
+ )
72
+ except (KeyError, TypeError, ValueError):
73
+ last_time = None
74
+ elif not os.path.isfile(HIST):
75
+ print("🌳 tt: no data")
76
+ return
77
+ else:
78
+ fh = open(HIST, encoding="utf-8")
79
+ try:
80
+ scans, last, last_time = _collect_history(fh, session, files)
81
+ finally:
82
+ fh.close()
83
+
84
+ if not files and not scans:
85
+ print("🌳 tt: 0 docs consulted")
86
+ return
87
+
88
+ dirs = {os.path.dirname(p) for p in files}
89
+ depth = max((p.count("/") for p in files), default=0)
90
+ grade = mature_grade()
91
+ stats = f"{grade} · " if grade else ""
92
+ stats += f"{len(files)} files · {scans} searches · {len(dirs)} folders · depth {depth}"
93
+
94
+ age = 10**9
95
+ if last_time is not None:
96
+ age = time.time() - last_time.timestamp()
97
+
98
+ if age < 90:
99
+ dot, color = "●", FRESH
100
+ elif age < 600:
101
+ dot, color = "◐", WARM
102
+ else:
103
+ dot, color = "○", COLD
104
+
105
+ path = terminal_safe(last["path"]) + ("/" if last.get("t") == "scan" else "")
106
+ print(f"🌳 {stats} {color}{dot} {path}{RESET}")
107
+
108
+
109
+ def mature_grade():
110
+ """Read the optional public-safe badge cache; measuring badges have no grade."""
111
+ try:
112
+ payload = json.loads(open(BADGE, encoding="utf-8").read())
113
+ except (OSError, ValueError):
114
+ return None
115
+ message = payload.get("message")
116
+ match = re.fullmatch(r"([A-F]) \(\d{1,3}\)", message) if isinstance(message, str) else None
117
+ return match.group(1) if match else None
118
+
119
+
120
+ def _collect_history(fh, session, files):
121
+ scans, last, last_time = 0, None, None
122
+ for line in fh:
123
+ if f'"session":"{session}"' not in line and f'"session": "{session}"' not in line:
124
+ continue
125
+ try:
126
+ event = json.loads(line)
127
+ except json.JSONDecodeError:
128
+ continue
129
+ typ = event.get("t")
130
+ if typ == "read":
131
+ files[event["path"]] = True
132
+ elif typ == "scan":
133
+ scans += 1
134
+ else:
135
+ continue
136
+ try:
137
+ event_time = datetime.strptime(event["ts"], "%Y-%m-%dT%H:%M:%SZ").replace(
138
+ tzinfo=timezone.utc
139
+ )
140
+ except (KeyError, ValueError):
141
+ event_time = None
142
+ if last is None or (
143
+ event_time is not None and (last_time is None or event_time >= last_time)
144
+ ):
145
+ last, last_time = event, event_time
146
+ return scans, last, last_time
147
+
148
+
149
+ if __name__ == "__main__":
150
+ try:
151
+ main()
152
+ except Exception:
153
+ print("🌳 tt")
154
+ sys.exit(0)
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Print concise, deterministic router suggestions; keep full stats off stdout."""
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
10
+
11
+
12
+ def router_for(path, router_coverage=None):
13
+ """Return a router proven to exist in stats; never invent index.md."""
14
+ folder = path.rsplit("/", 1)[0] if "/" in path else "(root)"
15
+ for item in router_coverage or []:
16
+ if item.get("folder") == folder:
17
+ return item.get("router")
18
+ return None
19
+
20
+
21
+ def build_suggestions(stats):
22
+ suggestions = []
23
+ seen = set()
24
+
25
+ def add(key, text):
26
+ if key not in seen and len(suggestions) < 5:
27
+ seen.add(key)
28
+ suggestions.append(text)
29
+
30
+ for item in stats.get("review_candidates", []):
31
+ if item.get("classification") == "protected":
32
+ add(
33
+ ("protected", item["path"]),
34
+ f"Review, likely keep — rare-but-critical: {item['path']} — {'; '.join(item['why'])}. {item['caveat']}",
35
+ )
36
+ elif (
37
+ not item.get("template", False)
38
+ and item.get("router")
39
+ and not item.get("router_mentions_target", False)
40
+ ):
41
+ add(
42
+ ("gap", item["path"]),
43
+ f"Review candidate: add a link to {item['path']} in {item['router']} — both files exist and that router does not mention the target. {item['caveat']}",
44
+ )
45
+
46
+ # Pre-1.0 payloads lack enough repository evidence for a safe link edit.
47
+ for item in stats.get("untouched_detail", []) if not stats.get("review_candidates") else []:
48
+ if not item.get("referenced_from") and not item.get("template", False):
49
+ path = item["path"]
50
+ add(
51
+ ("gap", path),
52
+ f"Review routing for {path} — legacy telemetry shows no reads or references, but cannot verify an existing router target. Low reads can mean rare-but-critical; verify before editing.",
53
+ )
54
+
55
+ for coverage in stats.get("router_coverage", []):
56
+ router = coverage.get("router")
57
+ for path in coverage.get("unlisted", []):
58
+ add(
59
+ ("unlisted", path),
60
+ f"Review candidate: add a link to {path} in {router} — both files exist and the folder router does not mention the target.",
61
+ )
62
+
63
+ for folder in stats.get("folders", []):
64
+ name = folder["folder"]
65
+ if name.startswith(".claude/") or name == "(root)":
66
+ continue
67
+ if not folder.get("has_index") and folder.get("coverage", 0) < 0.5:
68
+ add(
69
+ ("folder", name),
70
+ f"Add a folder entry point in {name}/ (README.md, _index.md, or index.md) — {folder['touched']}/{folder['files']} files read and no existing router was found.",
71
+ )
72
+
73
+ for folder in stats.get("folders", []):
74
+ name = folder["folder"]
75
+ if (
76
+ folder.get("coverage") == 0
77
+ and name != "(root)"
78
+ and not name.startswith(".claude/")
79
+ and ("folder", name) not in seen
80
+ ):
81
+ add(
82
+ ("cold", name),
83
+ f"Review routing for {name}/ — none of its {folder['files']} files were read. Low reads can mean rare-but-critical; verify before archiving.",
84
+ )
85
+
86
+ for hunt in stats.get("search_activity", stats.get("hunting", [])):
87
+ if hunt.get("pattern") == "distributed":
88
+ add(
89
+ ("hunt", hunt["path"]),
90
+ f"Review routing for {hunt['path']} — {hunt['scans']} searches recur across {hunt['sessions']}/{hunt['total_sessions']} sessions ({hunt['tools']}); search telemetry is correlational, not proof of failed routing.",
91
+ )
92
+
93
+ for path in stats.get("unknown_reads", []):
94
+ add(
95
+ ("unknown", path),
96
+ f"Review inventory configuration for {path} — the file exists and was read, but is outside the current documentation inventory.",
97
+ )
98
+ return suggestions
99
+
100
+
101
+ def main():
102
+ result = subprocess.run(
103
+ [sys.executable, os.path.join(SCRIPT_DIR, "tt-stats.py")],
104
+ capture_output=True,
105
+ text=True,
106
+ check=True,
107
+ )
108
+ stats = json.loads(result.stdout)
109
+ totals = stats["totals"]
110
+ if stats["maturity"] == "cold-start":
111
+ print(
112
+ f"🌳 Not enough data yet ({totals['reads']} reads, {stats['sessions']} sessions) — suggestions need a few working sessions first."
113
+ )
114
+ return
115
+ print(
116
+ f"🌳 Suggestions — based on {totals['reads']} reads, {totals['scans']} searches, {stats['sessions']} sessions; proposes router edits only and changes nothing until you confirm."
117
+ )
118
+ suggestions = build_suggestions(stats)
119
+ if not suggestions:
120
+ print("No evidence-backed router changes found yet.")
121
+ return
122
+ for number, suggestion in enumerate(suggestions, 1):
123
+ print(f"{number}. {suggestion}")
124
+ print("Apply any of these? Reply with the numbers.")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """Print concise, client-aware instruction-maintenance tips."""
3
+
4
+ import argparse
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def markdown_lines(path):
10
+ try:
11
+ return len(path.read_text(encoding="utf-8").splitlines())
12
+ except (OSError, UnicodeError):
13
+ return 0
14
+
15
+
16
+ def has_paths_frontmatter(path):
17
+ try:
18
+ text = path.read_text(encoding="utf-8")
19
+ except (OSError, UnicodeError):
20
+ return True
21
+ if not text.startswith("---\n"):
22
+ return False
23
+ end = text.find("\n---", 4)
24
+ return end >= 0 and any(line.strip().startswith("paths:") for line in text[4:end].splitlines())
25
+
26
+
27
+ def claude_tips(root):
28
+ tips = [
29
+ "Run /memory periodically and remove stale or contradictory auto-memory entries; memory is editable context, not an enforced source of truth."
30
+ ]
31
+ instruction_files = [
32
+ path for path in (root / "CLAUDE.md", root / ".claude" / "CLAUDE.md") if path.is_file()
33
+ ]
34
+ if not instruction_files:
35
+ tips.append(
36
+ "Add a project CLAUDE.md with only durable commands, conventions, and architecture facts you repeatedly need to explain."
37
+ )
38
+ for path in instruction_files:
39
+ count = markdown_lines(path)
40
+ if count > 200:
41
+ tips.append(
42
+ f"Trim {path.relative_to(root)} ({count} lines): Anthropic recommends targeting under 200 lines and moving specialized guidance to scoped rules or skills."
43
+ )
44
+ break
45
+ rules_dir = root / ".claude" / "rules"
46
+ unscoped = (
47
+ sorted(
48
+ path.relative_to(root).as_posix()
49
+ for path in rules_dir.rglob("*.md")
50
+ if path.is_file() and not has_paths_frontmatter(path)
51
+ )
52
+ if rules_dir.is_dir()
53
+ else []
54
+ )
55
+ if unscoped:
56
+ examples = ", ".join(unscoped[:2])
57
+ suffix = " …" if len(unscoped) > 2 else ""
58
+ tips.append(
59
+ f"Review {len(unscoped)} always-loaded rule file(s) for path scoping ({examples}{suffix}); unconditional rules consume context in every session."
60
+ )
61
+ return tips[:4]
62
+
63
+
64
+ def codex_tips(root):
65
+ agents = root / "AGENTS.md"
66
+ tips = []
67
+ if not agents.is_file():
68
+ tips.append(
69
+ "Add AGENTS.md with repository navigation, build/test commands, conventions, and non-obvious constraints that Codex cannot infer reliably."
70
+ )
71
+ else:
72
+ tips.append(
73
+ "Review AGENTS.md after architecture or workflow changes so persistent Codex instructions do not drift from the repository."
74
+ )
75
+ text = agents.read_text(encoding="utf-8", errors="replace").lower()
76
+ if not any(token in text for token in ("test", "check", "verify", "ci")):
77
+ tips.append(
78
+ "Add exact verification commands to AGENTS.md; reproducible environment and test instructions reduce avoidable agent errors."
79
+ )
80
+ tips.append(
81
+ "Keep agent-facing indexes and commands executable and inspectable; use trigger-tree suggestions for evidence-backed navigation gaps."
82
+ )
83
+ return tips[:4]
84
+
85
+
86
+ def tips_for(client, root):
87
+ root = Path(root).resolve()
88
+ return claude_tips(root) if client == "claude" else codex_tips(root)
89
+
90
+
91
+ def parse_args(argv=None):
92
+ parser = argparse.ArgumentParser()
93
+ parser.add_argument("--client", required=True, choices=("claude", "codex"))
94
+ parser.add_argument("--project", default=os.getcwd())
95
+ return parser.parse_args(argv)
96
+
97
+
98
+ def main(argv=None):
99
+ args = parse_args(argv)
100
+ tips = tips_for(args.client, args.project)
101
+ print(f"🌳 {args.client.title()} maintenance tips — review only; nothing was changed.")
102
+ for number, tip in enumerate(tips, 1):
103
+ print(f"{number}. {tip}")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """Remove trigger-tree project wiring without deleting telemetry or ignore rules."""
3
+
4
+ import json
5
+ import os
6
+ import runpy
7
+
8
+ ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
9
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
10
+ SETUP = runpy.run_path(os.path.join(SCRIPT_DIR, "tt-setup.py"))
11
+ assert_safe_destination = SETUP["assert_safe_destination"]
12
+ atomic_write = SETUP["atomic_write"]
13
+ report = SETUP["report"]
14
+
15
+
16
+ def unregister_statusline():
17
+ path = os.path.join(ROOT, ".claude", "settings.json")
18
+ assert_safe_destination(path)
19
+ if not os.path.isfile(path):
20
+ report("skipped", ".claude/settings.json (absent)")
21
+ return
22
+ try:
23
+ settings = json.load(open(path, encoding="utf-8"))
24
+ except json.JSONDecodeError:
25
+ report("skipped", ".claude/settings.json (unparseable — remove statusLine manually)")
26
+ return
27
+ status = settings.get("statusLine")
28
+ command = status.get("command", "") if isinstance(status, dict) else ""
29
+ if "tt-statusline.py" not in command:
30
+ report("skipped", ".claude/settings.json (foreign statusLine left untouched)")
31
+ return
32
+ del settings["statusLine"]
33
+ atomic_write(path, json.dumps(settings, indent=2) + "\n")
34
+ report("updated", ".claude/settings.json (trigger-tree statusLine removed)")
35
+
36
+
37
+ def remove_statusline_script():
38
+ path = os.path.join(ROOT, ".claude", "tt-statusline.py")
39
+ assert_safe_destination(path)
40
+ if not os.path.isfile(path):
41
+ report("skipped", ".claude/tt-statusline.py (absent)")
42
+ return
43
+ os.unlink(path)
44
+ report("removed", ".claude/tt-statusline.py")
45
+
46
+
47
+ def main():
48
+ unregister_statusline()
49
+ remove_statusline_script()
50
+ print(
51
+ "kept .trigger-tree/ telemetry and .gitignore entries; "
52
+ "delete them manually if you want to erase local history"
53
+ )
54
+
55
+
56
+ if __name__ == "__main__":
57
+ try:
58
+ main()
59
+ except Exception as exc:
60
+ print(f"skipped uninstall ({exc})")