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,656 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""trigger-tree logger — invoked by the plugin hooks. Stdlib only, no jq needed.
|
|
3
|
+
|
|
4
|
+
Events (first argument):
|
|
5
|
+
session SessionStart hook
|
|
6
|
+
prompt UserPromptSubmit hook (respects TT_LOG_PROMPTS: truncate|hash|off)
|
|
7
|
+
read PostToolUse on Read|Glob|Grep (Read → "read", Glob/Grep → "scan")
|
|
8
|
+
bash PostToolUse on Bash (rg/grep/find doc targets → "scan";
|
|
9
|
+
cat/head/tail/sed/awk doc files → "read")
|
|
10
|
+
skill PostToolUse on Skill (logs the skill name)
|
|
11
|
+
note manual annotation: tt-log.py note "text" (e.g. "sharpened UX router")
|
|
12
|
+
ingest external adapter entry point: tt-log.py ingest '{"t":"read","path":"docs/x.md"}'
|
|
13
|
+
— lets any tool (a Codex wrapper, a git hook) append telemetry through a
|
|
14
|
+
stable interface. Missing ts/session are stamped; unknown/invalid events
|
|
15
|
+
are dropped silently.
|
|
16
|
+
|
|
17
|
+
Appends one JSON line per event to $PROJECT/.trigger-tree/history.jsonl and rotates
|
|
18
|
+
the file to history-<utc-timestamp>.jsonl when it exceeds TT_ROTATE_BYTES.
|
|
19
|
+
Hooks must never disturb the session: every failure exits 0 silently.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import glob
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import posixpath
|
|
27
|
+
import re
|
|
28
|
+
import shlex
|
|
29
|
+
import stat
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
from tt_runtime import project_root
|
|
36
|
+
|
|
37
|
+
ROOT = project_root()
|
|
38
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
39
|
+
SCHEMA_VERSION = 1
|
|
40
|
+
|
|
41
|
+
DEFAULTS = {
|
|
42
|
+
"TT_WATCH_REGEX": (
|
|
43
|
+
r"^(docs|agents|skills|agent-briefs)/.*\.md$|^\.claude/(rules|skills)/.*\.md$|"
|
|
44
|
+
r"^(CLAUDE|AGENTS|GEMINI)\.md$"
|
|
45
|
+
),
|
|
46
|
+
"TT_SCAN_REGEX": r"^(docs|agents|skills|agent-briefs)(/|$)",
|
|
47
|
+
"TT_LOG_PROMPTS": "truncate",
|
|
48
|
+
"TT_ROTATE_BYTES": "5242880",
|
|
49
|
+
"TT_EXPERIMENTAL_OUTCOMES": "off",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def conf():
|
|
54
|
+
# Layered per key: plugin default first, project override wins where present.
|
|
55
|
+
out = dict(DEFAULTS)
|
|
56
|
+
for path in (
|
|
57
|
+
os.path.join(SCRIPT_DIR, "tt-config.sh"),
|
|
58
|
+
os.path.join(ROOT, ".trigger-tree", "config.sh"),
|
|
59
|
+
):
|
|
60
|
+
try:
|
|
61
|
+
text = open(path, encoding="utf-8").read()
|
|
62
|
+
except OSError:
|
|
63
|
+
continue
|
|
64
|
+
for key in DEFAULTS:
|
|
65
|
+
m = re.search(key + r"='([^']+)'", text)
|
|
66
|
+
if m:
|
|
67
|
+
out[key] = m.group(1)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def now_ts():
|
|
72
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _session_state_path(hist_dir, session):
|
|
76
|
+
name = hashlib.sha256(str(session).encode("utf-8")).hexdigest()[:32] + ".json"
|
|
77
|
+
return os.path.join(hist_dir, "sessions", name)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _update_session_state(hist_dir, obj):
|
|
81
|
+
"""Keep statusline work bounded and independent from history rotation."""
|
|
82
|
+
if not obj.get("session") or (
|
|
83
|
+
obj.get("t") not in ("session", "read", "scan") and not obj.get("tool_use_id")
|
|
84
|
+
):
|
|
85
|
+
return
|
|
86
|
+
state_dir = os.path.join(hist_dir, "sessions")
|
|
87
|
+
if os.path.lexists(state_dir):
|
|
88
|
+
mode = os.lstat(state_dir).st_mode
|
|
89
|
+
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
|
90
|
+
return
|
|
91
|
+
else:
|
|
92
|
+
os.makedirs(state_dir, mode=0o700)
|
|
93
|
+
try:
|
|
94
|
+
os.chmod(state_dir, 0o700)
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
path = _session_state_path(hist_dir, obj["session"])
|
|
98
|
+
seeded_from_history = False
|
|
99
|
+
try:
|
|
100
|
+
state = json.loads(open(path, encoding="utf-8").read())
|
|
101
|
+
except (OSError, ValueError):
|
|
102
|
+
if obj["t"] == "session" and obj.get("source") == "startup":
|
|
103
|
+
# A fresh SessionStart cannot have earlier reads. Create its empty cache
|
|
104
|
+
# now so the first read never scans every archive while holding the lock.
|
|
105
|
+
state = {"files": [], "scans": 0, "last": None, "recent_events": []}
|
|
106
|
+
else:
|
|
107
|
+
# Resume/compaction and pre-cache upgrades may already have history.
|
|
108
|
+
state = _session_state_from_history(hist_dir, obj["session"])
|
|
109
|
+
seeded_from_history = True
|
|
110
|
+
state.setdefault("recent_events", [])
|
|
111
|
+
if obj.get("tool_use_id"):
|
|
112
|
+
state["recent_events"] = (state["recent_events"] + [_event_identity(obj)])[-64:]
|
|
113
|
+
if obj["t"] == "session":
|
|
114
|
+
pass
|
|
115
|
+
elif seeded_from_history:
|
|
116
|
+
# The caller appends before updating the cache, so migration already saw
|
|
117
|
+
# this event in history and must not apply it a second time.
|
|
118
|
+
pass
|
|
119
|
+
elif obj["t"] == "read":
|
|
120
|
+
state["files"] = sorted(set(state.get("files", [])) | {obj["path"]})
|
|
121
|
+
state["last"] = {"t": obj["t"], "path": obj["path"], "ts": obj.get("ts", "")}
|
|
122
|
+
elif obj["t"] == "scan":
|
|
123
|
+
state["scans"] = int(state.get("scans", 0)) + 1
|
|
124
|
+
state["last"] = {"t": obj["t"], "path": obj["path"], "ts": obj.get("ts", "")}
|
|
125
|
+
fd, temporary = tempfile.mkstemp(prefix=".session.", dir=state_dir, text=True)
|
|
126
|
+
try:
|
|
127
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
128
|
+
json.dump(state, handle, ensure_ascii=False, separators=(",", ":"))
|
|
129
|
+
os.chmod(temporary, 0o600)
|
|
130
|
+
os.replace(temporary, path)
|
|
131
|
+
finally:
|
|
132
|
+
if os.path.exists(temporary):
|
|
133
|
+
os.unlink(temporary)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _session_state_from_history(hist_dir, session):
|
|
137
|
+
files, scans, last = set(), 0, None
|
|
138
|
+
for history in sorted(glob.glob(os.path.join(hist_dir, "history*.jsonl"))):
|
|
139
|
+
try:
|
|
140
|
+
lines = open(history, encoding="utf-8")
|
|
141
|
+
except OSError:
|
|
142
|
+
continue
|
|
143
|
+
with lines:
|
|
144
|
+
for line in lines:
|
|
145
|
+
try:
|
|
146
|
+
event = json.loads(line)
|
|
147
|
+
except ValueError:
|
|
148
|
+
continue
|
|
149
|
+
if event.get("session") != session or event.get("t") not in ("read", "scan"):
|
|
150
|
+
continue
|
|
151
|
+
if event["t"] == "read":
|
|
152
|
+
files.add(event["path"])
|
|
153
|
+
else:
|
|
154
|
+
scans += 1
|
|
155
|
+
if last is None or event.get("ts", "") >= last.get("ts", ""):
|
|
156
|
+
last = {"t": event["t"], "path": event["path"], "ts": event.get("ts", "")}
|
|
157
|
+
return {"files": sorted(files), "scans": scans, "last": last, "recent_events": []}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _event_identity(obj):
|
|
161
|
+
"""Return the bounded idempotency identity for one tool-backed event."""
|
|
162
|
+
return [str(obj.get("tool_use_id", "")), str(obj.get("t", "")), str(obj.get("path", ""))]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _already_recorded(hist_dir, obj):
|
|
166
|
+
"""Drop duplicate harness deliveries without scanning telemetry history."""
|
|
167
|
+
if not obj.get("tool_use_id") or not obj.get("session"):
|
|
168
|
+
return False
|
|
169
|
+
path = _session_state_path(hist_dir, obj["session"])
|
|
170
|
+
try:
|
|
171
|
+
mode = os.lstat(path).st_mode
|
|
172
|
+
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
|
|
173
|
+
return False
|
|
174
|
+
state = json.loads(open(path, encoding="utf-8").read())
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
return False
|
|
177
|
+
return _event_identity(obj) in state.get("recent_events", [])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def append(obj, rotate_bytes):
|
|
181
|
+
obj.setdefault("schema_version", SCHEMA_VERSION)
|
|
182
|
+
hist_dir = os.path.join(ROOT, ".trigger-tree")
|
|
183
|
+
if os.path.lexists(hist_dir):
|
|
184
|
+
mode = os.lstat(hist_dir).st_mode
|
|
185
|
+
if not stat.S_ISDIR(mode) or stat.S_ISLNK(mode):
|
|
186
|
+
return
|
|
187
|
+
else:
|
|
188
|
+
os.makedirs(hist_dir, mode=0o700)
|
|
189
|
+
try:
|
|
190
|
+
os.chmod(hist_dir, 0o700)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
lock_path = os.path.join(hist_dir, "write.lock")
|
|
194
|
+
if os.path.lexists(lock_path):
|
|
195
|
+
mode = os.lstat(lock_path).st_mode
|
|
196
|
+
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
|
|
197
|
+
return
|
|
198
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
|
|
199
|
+
lock_fd = os.open(lock_path, flags, 0o600)
|
|
200
|
+
if not stat.S_ISREG(os.fstat(lock_fd).st_mode):
|
|
201
|
+
os.close(lock_fd)
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
if os.name == "nt": # pragma: no cover - exercised by Windows CI
|
|
205
|
+
import msvcrt
|
|
206
|
+
|
|
207
|
+
if os.path.getsize(lock_path) == 0:
|
|
208
|
+
os.write(lock_fd, b"0")
|
|
209
|
+
os.lseek(lock_fd, 0, os.SEEK_SET)
|
|
210
|
+
msvcrt.locking(lock_fd, msvcrt.LK_LOCK, 1)
|
|
211
|
+
else: # pragma: no cover - POSIX branch, covered on Linux/macOS CI
|
|
212
|
+
import fcntl
|
|
213
|
+
|
|
214
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
215
|
+
_append_locked(obj, rotate_bytes, hist_dir)
|
|
216
|
+
finally:
|
|
217
|
+
if os.name == "nt": # pragma: no cover - exercised by Windows CI
|
|
218
|
+
import msvcrt
|
|
219
|
+
|
|
220
|
+
os.lseek(lock_fd, 0, os.SEEK_SET)
|
|
221
|
+
msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
|
|
222
|
+
else: # pragma: no cover - POSIX branch, covered on Linux/macOS CI
|
|
223
|
+
import fcntl
|
|
224
|
+
|
|
225
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
226
|
+
os.close(lock_fd)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _append_locked(obj, rotate_bytes, hist_dir):
|
|
230
|
+
if _already_recorded(hist_dir, obj):
|
|
231
|
+
return
|
|
232
|
+
hist = os.path.join(hist_dir, "history.jsonl")
|
|
233
|
+
try:
|
|
234
|
+
if os.path.lexists(hist):
|
|
235
|
+
mode = os.lstat(hist).st_mode
|
|
236
|
+
if not stat.S_ISREG(mode) or stat.S_ISLNK(mode):
|
|
237
|
+
return
|
|
238
|
+
if os.path.getsize(hist) > rotate_bytes:
|
|
239
|
+
stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
|
|
240
|
+
archive = os.path.join(hist_dir, f"history-{stamp}.jsonl")
|
|
241
|
+
suffix = 1
|
|
242
|
+
while os.path.exists(archive):
|
|
243
|
+
archive = os.path.join(hist_dir, f"history-{stamp}-{suffix}.jsonl")
|
|
244
|
+
suffix += 1
|
|
245
|
+
os.rename(hist, archive)
|
|
246
|
+
try:
|
|
247
|
+
os.chmod(archive, 0o600)
|
|
248
|
+
except OSError:
|
|
249
|
+
pass
|
|
250
|
+
except OSError:
|
|
251
|
+
pass
|
|
252
|
+
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
|
|
253
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
254
|
+
fd = os.open(hist, flags, 0o600)
|
|
255
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
256
|
+
os.close(fd)
|
|
257
|
+
return
|
|
258
|
+
try:
|
|
259
|
+
os.chmod(hist, 0o600)
|
|
260
|
+
except OSError:
|
|
261
|
+
pass
|
|
262
|
+
with os.fdopen(fd, "a", encoding="utf-8") as fh:
|
|
263
|
+
fh.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
264
|
+
_update_session_state(hist_dir, obj)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def rel_path(target):
|
|
268
|
+
# Normalize to forward slashes so logged paths are identical on all platforms.
|
|
269
|
+
t = target.replace("\\", "/")
|
|
270
|
+
root = ROOT.replace("\\", "/").rstrip("/") + "/"
|
|
271
|
+
return t[len(root) :] if t.startswith(root) else t
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def shell_segments(command):
|
|
275
|
+
"""Tokenize a shell command without executing it, split at control operators."""
|
|
276
|
+
try:
|
|
277
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&")
|
|
278
|
+
lexer.whitespace_split = True
|
|
279
|
+
tokens = list(lexer)
|
|
280
|
+
except ValueError:
|
|
281
|
+
return []
|
|
282
|
+
segments, current = [], []
|
|
283
|
+
for token in tokens:
|
|
284
|
+
if token and all(ch in "|;&" for ch in token):
|
|
285
|
+
if current:
|
|
286
|
+
segments.append(current)
|
|
287
|
+
current = []
|
|
288
|
+
else:
|
|
289
|
+
current.append(token)
|
|
290
|
+
if current:
|
|
291
|
+
segments.append(current)
|
|
292
|
+
return segments
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def bash_scan_paths(command, scan_regex):
|
|
296
|
+
"""Return doc targets of explicit rg/grep/find commands, never their contents.
|
|
297
|
+
|
|
298
|
+
Only existing path arguments are considered. This deliberately avoids guessing
|
|
299
|
+
that arbitrary Bash commands or search patterns are documentation lookups.
|
|
300
|
+
Multiple file arguments collapse to their common directory so one shell search
|
|
301
|
+
produces one scan event rather than inflating the hunting count.
|
|
302
|
+
"""
|
|
303
|
+
found = []
|
|
304
|
+
for segment in shell_segments(command):
|
|
305
|
+
tool_i = None
|
|
306
|
+
for i, token in enumerate(segment):
|
|
307
|
+
if os.path.basename(token).lower() in ("rg", "grep", "find"):
|
|
308
|
+
tool_i = i
|
|
309
|
+
break
|
|
310
|
+
if tool_i is None:
|
|
311
|
+
continue
|
|
312
|
+
targets = []
|
|
313
|
+
for token in segment[tool_i + 1 :]:
|
|
314
|
+
candidate = token if os.path.isabs(token) else os.path.join(ROOT, token)
|
|
315
|
+
if os.path.exists(candidate):
|
|
316
|
+
rel = rel_path(os.path.abspath(candidate)).rstrip("/") or "."
|
|
317
|
+
if re.search(scan_regex, rel):
|
|
318
|
+
targets.append(rel if os.path.isdir(candidate) else posixpath.dirname(rel))
|
|
319
|
+
targets = [target for target in targets if target]
|
|
320
|
+
if targets:
|
|
321
|
+
common = posixpath.commonpath(targets)
|
|
322
|
+
if common not in found and re.search(scan_regex, common):
|
|
323
|
+
found.append(common)
|
|
324
|
+
return found
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def bash_read_paths(command, watch_regex):
|
|
328
|
+
"""Return existing watched files explicitly consumed by shell reader commands."""
|
|
329
|
+
found = []
|
|
330
|
+
readers = ("cat", "head", "tail", "sed", "awk", "get-content", "gc", "type")
|
|
331
|
+
for segment in shell_segments(command):
|
|
332
|
+
tool_i = None
|
|
333
|
+
for i, token in enumerate(segment):
|
|
334
|
+
if os.path.basename(token).lower() in readers:
|
|
335
|
+
tool_i = i
|
|
336
|
+
break
|
|
337
|
+
if tool_i is None:
|
|
338
|
+
continue
|
|
339
|
+
tool = os.path.basename(segment[tool_i]).lower()
|
|
340
|
+
for rel in reader_arg_paths(tool, segment[tool_i + 1 :], watch_regex):
|
|
341
|
+
if rel not in found:
|
|
342
|
+
found.append(rel)
|
|
343
|
+
return found
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def reader_arg_paths(tool, arguments, watch_regex, base_dir=None):
|
|
347
|
+
"""Filter expanded reader argv down to existing watched file paths."""
|
|
348
|
+
if tool == "sed" and any(
|
|
349
|
+
token == "--in-place" or token.startswith("--in-place=") or re.match(r"^-i", token)
|
|
350
|
+
for token in arguments
|
|
351
|
+
):
|
|
352
|
+
return []
|
|
353
|
+
base_dir = ROOT if base_dir is None else base_dir
|
|
354
|
+
found = []
|
|
355
|
+
for token in arguments:
|
|
356
|
+
candidate = token if os.path.isabs(token) else os.path.join(base_dir, token)
|
|
357
|
+
if not os.path.isfile(candidate):
|
|
358
|
+
continue
|
|
359
|
+
rel = rel_path(os.path.abspath(candidate))
|
|
360
|
+
if re.search(watch_regex, rel) and rel not in found:
|
|
361
|
+
found.append(rel)
|
|
362
|
+
return found
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def watch_suffix_hint(pattern):
|
|
366
|
+
"""Return one safe extension hint only when every regex branch shares it."""
|
|
367
|
+
suffixes = {f".{value}" for value in re.findall(r"\\\.([A-Za-z0-9]+)\$", pattern)}
|
|
368
|
+
return suffixes.pop() if len(suffixes) == 1 else ""
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def static_glob_prefix(pattern):
|
|
372
|
+
"""Return an explicit directory prefix before the first wildcard, or empty."""
|
|
373
|
+
value = str(pattern or "").replace("\\", "/").removeprefix("./")
|
|
374
|
+
wildcard = min((value.find(char) for char in "*?[" if char in value), default=len(value))
|
|
375
|
+
raw_prefix = value[:wildcard]
|
|
376
|
+
prefix = raw_prefix.rstrip("/")
|
|
377
|
+
if not prefix:
|
|
378
|
+
return ""
|
|
379
|
+
if raw_prefix.endswith("/"):
|
|
380
|
+
return prefix
|
|
381
|
+
return posixpath.dirname(prefix)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def configure_shell_capture(session, watch_regex):
|
|
385
|
+
"""Persist runtime reader wrappers into Claude Code's Bash preamble."""
|
|
386
|
+
env_file = os.environ.get("CLAUDE_ENV_FILE")
|
|
387
|
+
shell_capture = os.path.join(SCRIPT_DIR, "tt-shell-capture.sh")
|
|
388
|
+
if not env_file or not os.path.isfile(shell_capture):
|
|
389
|
+
return
|
|
390
|
+
values = {
|
|
391
|
+
"TT_SHELL_LOGGER": os.path.join(SCRIPT_DIR, "tt-log.py"),
|
|
392
|
+
"TT_SHELL_SESSION": session,
|
|
393
|
+
"TT_SHELL_WATCH_SUFFIX": watch_suffix_hint(watch_regex),
|
|
394
|
+
}
|
|
395
|
+
try:
|
|
396
|
+
with open(env_file, "a", encoding="utf-8") as fh:
|
|
397
|
+
fh.write("\n# trigger-tree runtime Bash read capture\n")
|
|
398
|
+
for key, value in values.items():
|
|
399
|
+
fh.write(f"export {key}={shlex.quote(value)}\n")
|
|
400
|
+
fh.write(f". {shlex.quote(shell_capture)}\n")
|
|
401
|
+
except OSError:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def git_head():
|
|
406
|
+
try:
|
|
407
|
+
return (
|
|
408
|
+
subprocess.run(
|
|
409
|
+
["git", "rev-parse", "HEAD"],
|
|
410
|
+
cwd=ROOT,
|
|
411
|
+
capture_output=True,
|
|
412
|
+
text=True,
|
|
413
|
+
timeout=2,
|
|
414
|
+
check=True,
|
|
415
|
+
).stdout.strip()
|
|
416
|
+
or None
|
|
417
|
+
)
|
|
418
|
+
except (OSError, subprocess.SubprocessError):
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def looks_like_test_command(command):
|
|
423
|
+
for segment in shell_segments(command):
|
|
424
|
+
words = [os.path.basename(word).lower() for word in segment]
|
|
425
|
+
if words[0] in ("pytest", "py.test") or words[:2] in (["cargo", "test"], ["go", "test"]):
|
|
426
|
+
return True
|
|
427
|
+
if (
|
|
428
|
+
len(words) >= 2
|
|
429
|
+
and words[0] in ("npm", "pnpm", "yarn", "bun")
|
|
430
|
+
and words[1]
|
|
431
|
+
in (
|
|
432
|
+
"test",
|
|
433
|
+
"run",
|
|
434
|
+
)
|
|
435
|
+
):
|
|
436
|
+
return True
|
|
437
|
+
if words[:2] in (["mix", "test"], ["swift", "test"], ["dotnet", "test"]):
|
|
438
|
+
return True
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def session_signals(session):
|
|
443
|
+
baseline = test_status = None
|
|
444
|
+
paths = sorted(glob.glob(os.path.join(ROOT, ".trigger-tree", "history*.jsonl")))
|
|
445
|
+
for path in paths:
|
|
446
|
+
try:
|
|
447
|
+
lines = open(path, encoding="utf-8", errors="replace")
|
|
448
|
+
except OSError:
|
|
449
|
+
continue
|
|
450
|
+
with lines:
|
|
451
|
+
for line in lines:
|
|
452
|
+
try:
|
|
453
|
+
event = json.loads(line)
|
|
454
|
+
except json.JSONDecodeError:
|
|
455
|
+
continue
|
|
456
|
+
if not isinstance(event, dict) or event.get("session") != session:
|
|
457
|
+
continue
|
|
458
|
+
if event.get("t") == "session" and event.get("git_head") and baseline is None:
|
|
459
|
+
baseline = event["git_head"]
|
|
460
|
+
elif event.get("t") == "test":
|
|
461
|
+
test_status = event.get("status")
|
|
462
|
+
return baseline, test_status
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def main():
|
|
466
|
+
event = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
467
|
+
cfg = conf()
|
|
468
|
+
rotate = int(cfg["TT_ROTATE_BYTES"])
|
|
469
|
+
ts = now_ts()
|
|
470
|
+
|
|
471
|
+
if event == "ingest":
|
|
472
|
+
try:
|
|
473
|
+
obj = json.loads(sys.argv[2])
|
|
474
|
+
except (IndexError, json.JSONDecodeError):
|
|
475
|
+
return
|
|
476
|
+
if obj.get("t") not in (
|
|
477
|
+
"read",
|
|
478
|
+
"scan",
|
|
479
|
+
"skill",
|
|
480
|
+
"note",
|
|
481
|
+
"prompt",
|
|
482
|
+
"session",
|
|
483
|
+
"test",
|
|
484
|
+
"outcome",
|
|
485
|
+
):
|
|
486
|
+
return
|
|
487
|
+
if obj["t"] in ("read", "scan"):
|
|
488
|
+
if not obj.get("path"):
|
|
489
|
+
return
|
|
490
|
+
obj["path"] = rel_path(str(obj["path"]))
|
|
491
|
+
obj.setdefault("ts", ts)
|
|
492
|
+
obj.setdefault("session", os.environ.get("CLAUDE_SESSION_ID", "external"))
|
|
493
|
+
obj.setdefault("agent", "external")
|
|
494
|
+
append(obj, rotate)
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
if event == "shell-read":
|
|
498
|
+
tool = os.path.basename(sys.argv[2]).lower() if len(sys.argv) > 2 else ""
|
|
499
|
+
if tool not in ("cat", "head", "tail", "sed", "awk", "get-content", "gc", "type"):
|
|
500
|
+
return
|
|
501
|
+
session = os.environ.get("TT_SHELL_SESSION") or os.environ.get("CLAUDE_SESSION_ID", "?")
|
|
502
|
+
for path in reader_arg_paths(tool, sys.argv[3:], cfg["TT_WATCH_REGEX"], os.getcwd()):
|
|
503
|
+
append(
|
|
504
|
+
{
|
|
505
|
+
"t": "read",
|
|
506
|
+
"ts": ts,
|
|
507
|
+
"session": session,
|
|
508
|
+
"tool": "Bash",
|
|
509
|
+
"path": path,
|
|
510
|
+
"agent": "runtime",
|
|
511
|
+
"capture": "expanded-argv",
|
|
512
|
+
},
|
|
513
|
+
rotate,
|
|
514
|
+
)
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
if event == "note":
|
|
518
|
+
text = " ".join(sys.argv[2:]).strip()[:300]
|
|
519
|
+
if text:
|
|
520
|
+
session = os.environ.get("CLAUDE_SESSION_ID", "?")
|
|
521
|
+
append({"t": "note", "ts": ts, "session": session, "text": text}, rotate)
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
data = json.load(sys.stdin)
|
|
526
|
+
except (json.JSONDecodeError, ValueError):
|
|
527
|
+
data = {}
|
|
528
|
+
session = data.get("session_id", "?")
|
|
529
|
+
agent = data.get("agent_type", "main")
|
|
530
|
+
agent_id = data.get("agent_id")
|
|
531
|
+
|
|
532
|
+
def hook_identity(entry):
|
|
533
|
+
if data.get("tool_use_id"):
|
|
534
|
+
entry["tool_use_id"] = data["tool_use_id"]
|
|
535
|
+
if agent_id:
|
|
536
|
+
entry["agent_id"] = agent_id
|
|
537
|
+
return entry
|
|
538
|
+
|
|
539
|
+
if event == "session":
|
|
540
|
+
configure_shell_capture(session, cfg["TT_WATCH_REGEX"])
|
|
541
|
+
append(
|
|
542
|
+
{
|
|
543
|
+
"t": "session",
|
|
544
|
+
"ts": ts,
|
|
545
|
+
"session": session,
|
|
546
|
+
"source": data.get("source", "unknown"),
|
|
547
|
+
"git_head": git_head(),
|
|
548
|
+
},
|
|
549
|
+
rotate,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
elif event == "prompt":
|
|
553
|
+
entry = {"t": "prompt", "ts": ts, "session": session}
|
|
554
|
+
mode = cfg["TT_LOG_PROMPTS"]
|
|
555
|
+
prompt = (data.get("prompt") or "").replace("\n", " ")
|
|
556
|
+
if mode == "truncate":
|
|
557
|
+
entry["prompt"] = prompt[:200]
|
|
558
|
+
elif mode == "hash":
|
|
559
|
+
entry["prompt_hash"] = hashlib.sha1(prompt.encode()).hexdigest()[:10]
|
|
560
|
+
# mode "off": marker only — fingerprints still work, no prompt text stored
|
|
561
|
+
append(entry, rotate)
|
|
562
|
+
|
|
563
|
+
elif event == "read":
|
|
564
|
+
tool = data.get("tool_name", "?")
|
|
565
|
+
tool_input = data.get("tool_input") or {}
|
|
566
|
+
if tool == "Read":
|
|
567
|
+
target, typ, regex = tool_input.get("file_path"), "read", cfg["TT_WATCH_REGEX"]
|
|
568
|
+
else:
|
|
569
|
+
target, typ, regex = tool_input.get("path"), "scan", cfg["TT_SCAN_REGEX"]
|
|
570
|
+
if not target and tool == "Glob":
|
|
571
|
+
target = static_glob_prefix(tool_input.get("pattern"))
|
|
572
|
+
elif not target and tool == "Grep":
|
|
573
|
+
target = static_glob_prefix(tool_input.get("glob"))
|
|
574
|
+
if not target:
|
|
575
|
+
return
|
|
576
|
+
rel = rel_path(target)
|
|
577
|
+
if not re.search(regex, rel):
|
|
578
|
+
return
|
|
579
|
+
append(
|
|
580
|
+
hook_identity(
|
|
581
|
+
{"t": typ, "ts": ts, "session": session, "tool": tool, "path": rel, "agent": agent}
|
|
582
|
+
),
|
|
583
|
+
rotate,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
elif event == "bash":
|
|
587
|
+
command = (data.get("tool_input") or {}).get("command", "")
|
|
588
|
+
if looks_like_test_command(command):
|
|
589
|
+
append({"t": "test", "ts": ts, "session": session, "status": "pass"}, rotate)
|
|
590
|
+
if os.environ.get("TT_RUNTIME_BASH_READS") != "1":
|
|
591
|
+
for path in bash_read_paths(command, cfg["TT_WATCH_REGEX"]):
|
|
592
|
+
append(
|
|
593
|
+
hook_identity(
|
|
594
|
+
{
|
|
595
|
+
"t": "read",
|
|
596
|
+
"ts": ts,
|
|
597
|
+
"session": session,
|
|
598
|
+
"tool": "Bash",
|
|
599
|
+
"path": path,
|
|
600
|
+
"agent": agent,
|
|
601
|
+
}
|
|
602
|
+
),
|
|
603
|
+
rotate,
|
|
604
|
+
)
|
|
605
|
+
for path in bash_scan_paths(command, cfg["TT_SCAN_REGEX"]):
|
|
606
|
+
append(
|
|
607
|
+
hook_identity(
|
|
608
|
+
{
|
|
609
|
+
"t": "scan",
|
|
610
|
+
"ts": ts,
|
|
611
|
+
"session": session,
|
|
612
|
+
"tool": "Bash",
|
|
613
|
+
"path": path,
|
|
614
|
+
"agent": agent,
|
|
615
|
+
}
|
|
616
|
+
),
|
|
617
|
+
rotate,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
elif event == "bash-failure":
|
|
621
|
+
command = (data.get("tool_input") or {}).get("command", "")
|
|
622
|
+
if looks_like_test_command(command):
|
|
623
|
+
append({"t": "test", "ts": ts, "session": session, "status": "fail"}, rotate)
|
|
624
|
+
|
|
625
|
+
elif event == "outcome":
|
|
626
|
+
baseline, test_status = session_signals(session)
|
|
627
|
+
current = git_head()
|
|
628
|
+
append(
|
|
629
|
+
{
|
|
630
|
+
"t": "outcome",
|
|
631
|
+
"ts": ts,
|
|
632
|
+
"session": session,
|
|
633
|
+
"git_commit_landed": bool(baseline and current and baseline != current),
|
|
634
|
+
"test_status": test_status or "unknown",
|
|
635
|
+
"source": data.get("reason", "unknown"),
|
|
636
|
+
},
|
|
637
|
+
rotate,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
elif event == "skill":
|
|
641
|
+
name = (data.get("tool_input") or {}).get("skill", "")
|
|
642
|
+
if name:
|
|
643
|
+
append(
|
|
644
|
+
hook_identity(
|
|
645
|
+
{"t": "skill", "ts": ts, "session": session, "skill": name, "agent": agent}
|
|
646
|
+
),
|
|
647
|
+
rotate,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
if __name__ == "__main__":
|
|
652
|
+
try:
|
|
653
|
+
main()
|
|
654
|
+
except Exception:
|
|
655
|
+
pass # a logging failure must never break the session
|
|
656
|
+
sys.exit(0)
|