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 @@
1
+ """trigger-tree — local, zero-token documentation-discovery telemetry (standalone CLI)."""
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """Translate Codex lifecycle hooks to trigger-tree's stable logger contract."""
3
+
4
+ import io
5
+ import json
6
+ import os
7
+ import runpy
8
+ import sys
9
+
10
+ from tt_runtime import project_root
11
+
12
+
13
+ def read_payload(stream):
14
+ try:
15
+ value = json.load(stream)
16
+ except (json.JSONDecodeError, ValueError):
17
+ return {}
18
+ return value if isinstance(value, dict) else {}
19
+
20
+
21
+ def normalize_tool(payload):
22
+ tool = payload.get("tool_name", "")
23
+ tool_input = payload.get("tool_input")
24
+ tool_input = dict(tool_input) if isinstance(tool_input, dict) else {}
25
+
26
+ if tool == "Bash":
27
+ tool_input["command"] = tool_input.get("command") or tool_input.get("cmd", "")
28
+ route = "bash"
29
+ elif tool in ("Read", "Glob", "Grep"):
30
+ route = "read"
31
+ elif tool == "Skill":
32
+ route = "skill"
33
+ elif tool.startswith("mcp__"):
34
+ target = next(
35
+ (
36
+ tool_input.get(key)
37
+ for key in ("file_path", "path", "filename", "uri")
38
+ if tool_input.get(key)
39
+ ),
40
+ None,
41
+ )
42
+ if not target or str(target).startswith(("http://", "https://")):
43
+ return None, payload
44
+ is_search = any(word in tool.lower() for word in ("search", "grep", "find"))
45
+ tool_input = {"path" if is_search else "file_path": target}
46
+ tool = "Grep" if is_search else "Read"
47
+ route = "read"
48
+ else:
49
+ return None, payload
50
+
51
+ normalized = dict(payload)
52
+ normalized["tool_name"] = tool
53
+ normalized["tool_input"] = tool_input
54
+ return route, normalized
55
+
56
+
57
+ def translate(payload):
58
+ event = payload.get("hook_event_name", "")
59
+ if event == "SessionStart":
60
+ normalized = dict(payload)
61
+ normalized["source"] = payload.get("source", "codex")
62
+ return "session", normalized
63
+ if event == "UserPromptSubmit":
64
+ return "prompt", payload
65
+ if event == "PostToolUse":
66
+ return normalize_tool(payload)
67
+ if event == "PostToolUseFailure" and payload.get("tool_name") == "Bash":
68
+ return "bash-failure", payload
69
+ if event == "SessionEnd":
70
+ normalized = dict(payload)
71
+ normalized["reason"] = payload.get("reason", "claude-session-end")
72
+ return "outcome", normalized
73
+ if event == "Stop":
74
+ if not os.environ.get("PLUGIN_ROOT"):
75
+ return None, payload
76
+ normalized = dict(payload)
77
+ normalized["reason"] = payload.get("reason", "codex-stop")
78
+ return "outcome", normalized
79
+ return None, payload
80
+
81
+
82
+ def main():
83
+ # Codex documents PLUGIN_ROOT for plugin-bundled hooks. Claude compatibility
84
+ # exports CLAUDE_PLUGIN_ROOT but not this Codex-specific presence marker.
85
+ if "--client" in sys.argv:
86
+ try:
87
+ client = sys.argv[sys.argv.index("--client") + 1]
88
+ except IndexError:
89
+ client = ""
90
+ if client == "codex" and not os.environ.get("PLUGIN_ROOT"):
91
+ return
92
+ payload = read_payload(sys.stdin)
93
+ route, normalized = translate(payload)
94
+ if not route:
95
+ return
96
+
97
+ script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tt-log.py")
98
+ old_argv, old_stdin = sys.argv, sys.stdin
99
+ old_root = os.environ.get("TT_PROJECT_DIR")
100
+ try:
101
+ os.environ["TT_PROJECT_DIR"] = project_root(normalized.get("cwd"))
102
+ sys.argv = [script, route]
103
+ sys.stdin = io.StringIO(json.dumps(normalized))
104
+ runpy.run_path(script, run_name="__main__")
105
+ except SystemExit:
106
+ pass
107
+ finally:
108
+ sys.argv, sys.stdin = old_argv, old_stdin
109
+ if old_root is None:
110
+ os.environ.pop("TT_PROJECT_DIR", None)
111
+ else:
112
+ os.environ["TT_PROJECT_DIR"] = old_root
113
+
114
+
115
+ if __name__ == "__main__":
116
+ try:
117
+ main()
118
+ except Exception:
119
+ pass
120
+ sys.exit(0)
@@ -0,0 +1,32 @@
1
+ # trigger-tree — default configuration.
2
+ # Override per project: create $PROJECT/.trigger-tree/config.sh with the same variables.
3
+ # Paths are relative to the project root.
4
+
5
+ # A Read of a file matching this regex counts as a documentation read.
6
+ TT_WATCH_REGEX='^(docs|agents|skills|agent-briefs)/.*\.md$|^\.claude/(rules|skills)/.*\.md$|^(CLAUDE|AGENTS|GEMINI)\.md$'
7
+
8
+ # A Glob/Grep whose explicit target dir matches this regex counts as search activity.
9
+ # The event records that a search happened, not why it happened or whether routing failed.
10
+ TT_SCAN_REGEX='^(docs|agents|skills|agent-briefs)(/|$)'
11
+
12
+ # Files matching this regex are loaded automatically (system-prompt injection, nested
13
+ # CLAUDE.md on-demand loading, Skill tool) and therefore cannot be judged through
14
+ # Read telemetry — excluded from untouched review-candidate analysis.
15
+ TT_ALWAYS_LOADED_REGEX='(^|/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|/)CLAUDE\.local\.md$|^\.claude/skills/'
16
+ # Example project override additions for other instruction systems:
17
+ # TT_ALWAYS_LOADED_REGEX='...|^\.github/copilot-instructions\.md$|^\.cursor/rules/'
18
+
19
+ # Comma-separated globs for rare-but-critical documentation that must be reviewed,
20
+ # never treated as an archive candidate. Safety paths are protected regardless.
21
+ TT_CRITICAL_GLOB=''
22
+
23
+ # Default: a recognizable preview of the first 200 characters, stored only in the
24
+ # project's gitignored telemetry. Setup offers truncate, hash, and off explicitly.
25
+ TT_LOG_PROMPTS='truncate'
26
+
27
+ # Rotate history.jsonl to history-<timestamp>.jsonl when it exceeds this many bytes.
28
+ TT_ROTATE_BYTES='5242880'
29
+
30
+ # Experimental, correlational view joining reads with local session outcomes.
31
+ # Values: off (default) or on. This never makes causal claims or sends data anywhere.
32
+ TT_EXPERIMENTAL_OUTCOMES='off'
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env python3
2
+ """Explain whether trigger-tree is installed and receiving usable telemetry."""
3
+
4
+ import glob
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ import time
10
+ from datetime import datetime
11
+
12
+ from tt_scope import is_poor_coverage, scan_markdown
13
+
14
+ ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
15
+ PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16
+ SCHEMA_VERSION = 1
17
+ SUPPORTED_PYTHON = (3, 10), (3, 13)
18
+
19
+
20
+ def load_json(path):
21
+ try:
22
+ with open(path, encoding="utf-8") as fh:
23
+ return json.load(fh)
24
+ except (OSError, json.JSONDecodeError):
25
+ return None
26
+
27
+
28
+ def history_health():
29
+ paths = sorted(glob.glob(os.path.join(ROOT, ".trigger-tree", "history*.jsonl")))
30
+ if not paths:
31
+ return "WARN", "telemetry: no history yet — start a fresh session and read a doc"
32
+ valid = corrupt = legacy = future = 0
33
+ latest = None
34
+ try:
35
+ for path in paths:
36
+ with open(path, encoding="utf-8", errors="replace") as fh:
37
+ for line in fh:
38
+ try:
39
+ event = json.loads(line)
40
+ except json.JSONDecodeError:
41
+ corrupt += 1
42
+ continue
43
+ if not isinstance(event, dict) or not event.get("t"):
44
+ corrupt += 1
45
+ continue
46
+ version = event.get("schema_version", 0)
47
+ if version == 0:
48
+ legacy += 1
49
+ elif version != SCHEMA_VERSION:
50
+ future += 1
51
+ continue
52
+ valid += 1
53
+ latest = event.get("ts") or latest
54
+ except OSError:
55
+ return "FAIL", "telemetry: history exists but cannot be read"
56
+ if future:
57
+ return (
58
+ "FAIL",
59
+ f"telemetry: {future} event(s) use a newer schema — update trigger-tree before reading this history",
60
+ )
61
+ if not valid:
62
+ return (
63
+ "FAIL",
64
+ "telemetry: history contains no usable events — move corrupt logs aside or restart telemetry",
65
+ )
66
+ suffix = f", latest {latest}" if latest else ""
67
+ rotation = f", {len(paths) - 1} rotated file(s) included" if len(paths) > 1 else ""
68
+ migration = f", {legacy} legacy event(s) migrated" if legacy else ""
69
+ if corrupt:
70
+ return (
71
+ "WARN",
72
+ f"telemetry: {valid} usable events{suffix}{rotation}{migration}; {corrupt} corrupt line(s) ignored — inspect history*.jsonl",
73
+ )
74
+ return "PASS", f"telemetry: {valid} usable events{suffix}{rotation}{migration}"
75
+
76
+
77
+ def hooks_health():
78
+ claude_manifest = load_json(os.path.join(PLUGIN_ROOT, "hooks", "claude-hooks.json"))
79
+ hooks = claude_manifest.get("hooks", {}) if isinstance(claude_manifest, dict) else {}
80
+ commands = json.dumps(claude_manifest) if claude_manifest else ""
81
+ claude_ok = {
82
+ "SessionStart",
83
+ "UserPromptSubmit",
84
+ "PostToolUse",
85
+ "PostToolUseFailure",
86
+ "SessionEnd",
87
+ }.issubset(hooks) and all(
88
+ marker in commands
89
+ for marker in (
90
+ "Bash",
91
+ "Read",
92
+ "Glob",
93
+ "Grep",
94
+ "Skill",
95
+ "tt-codex-hook.py",
96
+ "--client",
97
+ "claude",
98
+ )
99
+ )
100
+ codex_manifest = load_json(os.path.join(PLUGIN_ROOT, "hooks", "hooks.json"))
101
+ codex_hooks = codex_manifest.get("hooks", {}) if isinstance(codex_manifest, dict) else {}
102
+ codex_commands = json.dumps(codex_manifest) if codex_manifest else ""
103
+ codex_ok = {"SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"}.issubset(
104
+ codex_hooks
105
+ ) and all(
106
+ marker in codex_commands
107
+ for marker in ("tt-codex-hook.py", "CLAUDE_PLUGIN_ROOT", "--client codex")
108
+ )
109
+ if claude_ok and codex_ok:
110
+ return "PASS", "plugin hook files: Claude Code and Codex routes are intact"
111
+ return "FAIL", "plugin hook files: missing logger routes — reinstall the plugin"
112
+
113
+
114
+ def watch_regex():
115
+ for path in (
116
+ os.path.join(ROOT, ".trigger-tree", "config.sh"),
117
+ os.path.join(PLUGIN_ROOT, "scripts", "tt-config.sh"),
118
+ ):
119
+ try:
120
+ text = open(path, encoding="utf-8").read()
121
+ except OSError:
122
+ continue
123
+ match = re.search(r"TT_WATCH_REGEX='([^']+)'", text)
124
+ if match:
125
+ return match.group(1)
126
+ return r"(?!)"
127
+
128
+
129
+ def coverage_health():
130
+ result = scan_markdown(ROOT, watch_regex())
131
+ summary = f"coverage: {result['watched']} of {result['markdown']} markdown files watched"
132
+ remediation = "set TT_WATCH_REGEX in .trigger-tree/config.sh"
133
+ if result["markdown"] and result["watched"] == 0:
134
+ return "FAIL", f"{summary} — {remediation}"
135
+ if is_poor_coverage(result):
136
+ return "WARN", f"{summary} (very low) — {remediation}"
137
+ return "PASS", summary
138
+
139
+
140
+ def _lifecycle_events():
141
+ events = []
142
+ for path in sorted(glob.glob(os.path.join(ROOT, ".trigger-tree", "history*.jsonl"))):
143
+ try:
144
+ lines = open(path, encoding="utf-8", errors="replace")
145
+ except OSError:
146
+ continue
147
+ with lines:
148
+ for line in lines:
149
+ try:
150
+ event = json.loads(line)
151
+ except json.JSONDecodeError:
152
+ continue
153
+ if isinstance(event, dict) and event.get("t") in ("session", "read"):
154
+ events.append(event)
155
+ return events
156
+
157
+
158
+ def liveness_health():
159
+ events = _lifecycle_events()
160
+ current = os.environ.get("CLAUDE_SESSION_ID")
161
+ if current:
162
+ if any(event.get("t") == "session" and event.get("session") == current for event in events):
163
+ return "PASS", "hook liveness: current session start was recorded"
164
+ return (
165
+ "FAIL",
166
+ "hook liveness: current session start is absent — check /hooks, restart the session, then reinstall the plugin if still absent",
167
+ )
168
+ state_paths = glob.glob(os.path.join(ROOT, ".trigger-tree", "sessions", "*.json"))
169
+ newest_state = max((os.path.getmtime(path) for path in state_paths), default=0)
170
+ timestamps = []
171
+ for event in events:
172
+ try:
173
+ timestamps.append(
174
+ datetime.fromisoformat(event.get("ts", "").replace("Z", "+00:00")).timestamp()
175
+ )
176
+ except (AttributeError, TypeError, ValueError):
177
+ pass
178
+ newest = max(timestamps + [newest_state], default=0)
179
+ if not newest:
180
+ return (
181
+ "WARN",
182
+ "hook liveness: no hook events have ever been recorded — run /tt setup and start a fresh session",
183
+ )
184
+ age_days = max(0, int((time.time() - newest) / 86400))
185
+ if age_days > 7:
186
+ return (
187
+ "WARN",
188
+ f"hook liveness: events exist but are stale ({age_days} days) — informational; start a fresh session to verify",
189
+ )
190
+ return "PASS", "hook liveness: recent session/read activity found"
191
+
192
+
193
+ def config_health():
194
+ path = os.path.join(ROOT, ".trigger-tree", "config.sh")
195
+ if not os.path.isfile(path):
196
+ return "PASS", "config: defaults valid (no project override)"
197
+ try:
198
+ text = open(path, encoding="utf-8").read()
199
+ except OSError:
200
+ return "FAIL", "config: project override cannot be read — fix permissions or remove it"
201
+ assignments = dict(re.findall(r"(?m)^(TT_[A-Z_]+)='([^']*)'\s*$", text))
202
+ malformed = [
203
+ line.strip()
204
+ for line in text.splitlines()
205
+ if line.strip().startswith("TT_") and not re.match(r"^TT_[A-Z_]+='[^']*'\s*$", line.strip())
206
+ ]
207
+ if malformed:
208
+ return "FAIL", f"config: unparseable assignment `{malformed[0]}` — use KEY='value'"
209
+ for name in ("TT_WATCH_REGEX", "TT_SCAN_REGEX", "TT_ALWAYS_LOADED_REGEX"):
210
+ if name in assignments:
211
+ try:
212
+ re.compile(assignments[name])
213
+ except re.error as exc:
214
+ return "FAIL", f"config: {name} is invalid ({exc}) — fix the regex"
215
+ prompt_mode = assignments.get("TT_LOG_PROMPTS")
216
+ if prompt_mode is not None and prompt_mode not in ("hash", "truncate", "off"):
217
+ return "FAIL", "config: TT_LOG_PROMPTS must be hash, truncate, or off"
218
+ experimental = assignments.get("TT_EXPERIMENTAL_OUTCOMES")
219
+ if experimental is not None and experimental not in ("on", "off"):
220
+ return "FAIL", "config: TT_EXPERIMENTAL_OUTCOMES must be on or off"
221
+ rotate = assignments.get("TT_ROTATE_BYTES")
222
+ if rotate is not None:
223
+ try:
224
+ if int(rotate) <= 0:
225
+ raise ValueError
226
+ except ValueError:
227
+ return "FAIL", "config: TT_ROTATE_BYTES must be a positive integer"
228
+ return "PASS", "config: project override parses and validates"
229
+
230
+
231
+ def python_health():
232
+ current = sys.version_info[:2]
233
+ if SUPPORTED_PYTHON[0] <= current <= SUPPORTED_PYTHON[1]:
234
+ return "PASS", f"python: {current[0]}.{current[1]} is supported (3.10–3.13)"
235
+ return "FAIL", f"python: {current[0]}.{current[1]} unsupported — configure Python 3.10–3.13"
236
+
237
+
238
+ def ignore_health():
239
+ path = os.path.join(ROOT, ".gitignore")
240
+ try:
241
+ lines = open(path, encoding="utf-8").read().splitlines()
242
+ except OSError:
243
+ lines = []
244
+ ignored = ".trigger-tree/" in lines or ".trigger-tree/*" in lines
245
+ if ignored:
246
+ return "PASS", "privacy: telemetry directory is gitignored"
247
+ return "FAIL", "privacy: .trigger-tree is not gitignored — run /tt setup"
248
+
249
+
250
+ def statusline_health():
251
+ script = os.path.join(ROOT, ".claude", "tt-statusline.py")
252
+ settings = load_json(os.path.join(ROOT, ".claude", "settings.json"))
253
+ status = settings.get("statusLine", {}) if isinstance(settings, dict) else {}
254
+ command = status.get("command", "") if isinstance(status, dict) else ""
255
+ if os.path.isfile(script) and "tt-statusline.py" in command:
256
+ return "PASS", "statusline: installed and registered"
257
+ return "WARN", "statusline: not fully wired (telemetry still works) — run /tt setup"
258
+
259
+
260
+ def main():
261
+ checks = [
262
+ hooks_health(),
263
+ liveness_health(),
264
+ config_health(),
265
+ coverage_health(),
266
+ python_health(),
267
+ ignore_health(),
268
+ statusline_health(),
269
+ history_health(),
270
+ ]
271
+ print("🌳 trigger-tree doctor")
272
+ for state, message in checks:
273
+ icon = {"PASS": "✓", "WARN": "!", "FAIL": "✗"}[state]
274
+ print(f"{icon} {message}")
275
+ failures = sum(state == "FAIL" for state, _ in checks)
276
+ warnings = sum(state == "WARN" for state, _ in checks)
277
+ if failures:
278
+ print(f"attention needed — {failures} failed, {warnings} warnings")
279
+ elif warnings:
280
+ plural = "s" if warnings != 1 else ""
281
+ print(f"telemetry healthy — {warnings} optional setup warning{plural}")
282
+ else:
283
+ print("all checks passed — telemetry is wired and receiving events")
284
+ return 1 if failures else 0
285
+
286
+
287
+ if __name__ == "__main__":
288
+ sys.exit(main())