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,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)