jevcompass 0.1.14__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.
jevcompass/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Privacy-first tool and skill advice for Codex Desktop and CLI."""
2
+
3
+ __version__ = "0.1.14"
jevcompass/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ import sys
2
+
3
+ if len(sys.argv) > 1 and sys.argv[1] == "hook":
4
+ from .advisor import hook_main
5
+
6
+ raise SystemExit(hook_main())
7
+
8
+ from .cli import main
9
+
10
+ raise SystemExit(main())
jevcompass/advisor.py ADDED
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env python3
2
+ """Non-blocking Codex hook advice from a locally curated Jev catalog."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import argparse
8
+ import json
9
+ import os
10
+ import secrets
11
+ from pathlib import Path
12
+ import re
13
+ import sys
14
+ import time
15
+ from typing import Any
16
+
17
+ def __getattr__(name: str) -> Any:
18
+ """Load catalog and decision helpers only when an event needs advice."""
19
+ if name in {"candidates", "catalog_version"}:
20
+ from importlib import import_module
21
+
22
+ module = import_module(".catalog", __package__)
23
+ elif name in {"DecisionsClient", "DecisionsError", "configured_model"}:
24
+ from importlib import import_module
25
+
26
+ module = import_module(".decisions", __package__)
27
+ else:
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
29
+ value = getattr(module, name)
30
+ globals()[name] = value
31
+ return value
32
+
33
+
34
+ MAX_EVENT_BYTES = 256_000
35
+ MAX_CONTEXT_CHARS = 1_500
36
+ JEV_TIMEOUT = 1.5
37
+ MIN_CONFIDENCE = 0.5
38
+ CACHE_DIR = Path.home() / ".cache/jevcompass"
39
+ LOG_PATH = Path.home() / ".local/state/jevcompass/advisor.jsonl"
40
+ ROLE_CATEGORIES = {
41
+ "explorer": ("codebase", "software"),
42
+ "worker": ("coding", "software"),
43
+ }
44
+ SPAWN_TOOL_NAMES = frozenset({"Agent", "spawn_agent", "collaborationspawn_agent"})
45
+ SPAWN_TEXT_LIMIT = 10_000
46
+ CATALOG_TASKS = {
47
+ "infrastructure": "ops",
48
+ "debugging": "debug",
49
+ "testing": "testing",
50
+ "research": "research",
51
+ "api-design": "api-design",
52
+ "documentation": "document",
53
+ "codex-setup": "document",
54
+ "coding": "code",
55
+ "codebase": "codebase",
56
+ "history": "history",
57
+ "review": "review",
58
+ "source-review": "source-review",
59
+ "planning": "planning",
60
+ "operations": "ops",
61
+ "project-setup": "project",
62
+ "package-docs": "package-docs",
63
+ }
64
+ TASK_PATTERNS = (
65
+ # Setup of Codex itself benefits from official product guidance; package setup does not.
66
+ ("codex-setup", re.compile(r"(?=.*\bcodex(?:'s)?\s+(?:desktop|cli|hooks?|settings|skills?)\b)(?=.*\b(?:hooks?|settings|skills?|configuration)\b)(?=.*\b(?:configur\w*|set\s+up|setup\s+codex|install\w*|skonfigur\w*|ustaw\w*)\b)", re.I)),
67
+ ("package-docs", re.compile(r"(?=.*\b(?:python|pyproject\.toml|pipx?)\b)(?=.*\b(?:readme|documentation|docs)\b)(?=.*\b(?:install(?:ation)?|installing)\b)", re.I)),
68
+ # A project mentioned as the location of a feature/test is not a new project.
69
+ ("project-setup", re.compile(r"\b(?:creat\w*|start\w*|bootstrap\w*|scaffold\w*|setup|set up|initialize\w*|initialise\w*|init|utwórz|założ\w*|stwórz|stworze\w*|zainicjaliz\w*)\b(?:(?!\b(?:tests?|features?|functions?|files?|scripts?|docs?|documentation|modules?|components?)\b)[\s\S]){0,80}?\b(?:repository|repo|repozytorium|package|pakiet|project|projekt)\b", re.I)),
70
+ ("api-design", re.compile(r"(?=.*\b(?:api|endpoint|openapi|rest|graphql)\b)(?:(?=.*\b(?:design\w*|architect\w*|defin\w*|specif\w*|zaprojekt\w*|projektow\w*)\b)|(?=.*\b(?:review|audit)\b)(?=.*\b(?:contract|schema|specification)\b))", re.I)),
71
+ ("infrastructure", re.compile(r"\b(kubernetes|kubectl|helm|k3s|deploy|deployment|cluster|terraform|infra|wdroż|klaster)\b", re.I)),
72
+ ("source-review", re.compile(r"^(?!.*\b(?:code|diff|pull request|implementation|api|readme)\b)(?=.*\b(?:review|audit|przegląd|przejrz|audyt)\w*\b)(?=.*\b(?:proposal|offer|(?:price|pricing|client|commercial)\s+quote|bid|draft|invoice|ofert|propozycj|wycen)\w*\b)", re.I)),
73
+ ("history", re.compile(r"(?=.*\b(?:git|commit|branch|repository|repo|code|source|function|file|module|repozytorium|kod|plik|funkcj|moduł)\w*\b)(?=.*(?:\bhistory\b|\bhistori\w*|\bprovenance\b|\bblame\b|\bintroduced\b|\bauthored\b|\bwho\s+(?:changed|introduced|authored)\b|\bkto\s+(?:zmienił|wprowadził)\b))", re.I)),
74
+ ("review", re.compile(r"\b(review|audit|diff|pull request|pr|przegląd|audyt)\b", re.I)),
75
+ ("debugging", re.compile(r"\b(debug|diagnos|bug|error|failure|regress|defect|napraw|błąd|awari)\w*|\bfix(?:es|ed|ing)?\b", re.I)),
76
+ ("planning", re.compile(r"\b(roadmap|task list|task breakdown|list of tasks|prepare.{0,60}task|list[ęa]\s+(?:tasków|taskow|zadań|zadan)|zaplanuj|przygotuj.{0,60}(?:task|zadani|plan)|opracuj\s+plan|priorytetyz\w*|plan\s+(?:a|an|the|this|how|for|to)\b|implementation plan|planowanie|planowania)\b", re.I)),
77
+ ("codebase", re.compile(r"\b(inspect|understand|explain|trace|investigat|how does|what does|przejrz|zrozum|wyjaśn)\w*", re.I)),
78
+ ("coding", re.compile(r"\b(implement|refactor|modify|zimplement|modyfik\w*)\w*", re.I)),
79
+ ("documentation", re.compile(r"\b(docs|documentation|readme|instrukcj|dokument)\w*", re.I)),
80
+ ("testing", re.compile(r"\b(test|pytest|unittest|ci|walidac|verify|weryfik)\w*", re.I)),
81
+ ("research", re.compile(r"\b(research|porówn|analiz|źródeł|źródł|search|browse)\w*", re.I)),
82
+ ("coding", re.compile(r"\b(write|add|code|coding|python|typescript|javascript|funkcj|implementac|program|dodaj|napisz)\w*", re.I)),
83
+ )
84
+ DOMAIN_PATTERNS = (
85
+ ("kubernetes", re.compile(r"\b(kubernetes|kubectl|helm|k3s|cluster|klaster)\b", re.I)),
86
+ ("codex", re.compile(r"\b(?:codex(?:'s)?\s+(?:desktop|cli|hooks?|settings|skills?|models?|setup|configuration|troubleshooting|customization)|(?:desktop|cli|hooks?|settings|skills?|models?|setup|configuration|troubleshooting|customization)\s+(?:in|for|with|on)\s+codex)\b", re.I)),
87
+ ("python", re.compile(r"\b(python|pytest|django|fastapi)\b|\.py\b", re.I)),
88
+ ("web", re.compile(r"\b(web|frontend|browser|react|typescript|javascript)\b", re.I)),
89
+ ("shell", re.compile(r"\b(bash|shell)\b", re.I)),
90
+ )
91
+ MIN_TASK_CHARS = 20
92
+ SIMPLE_REQUEST = re.compile(
93
+ r"^\s*(?:run|execute|show|list|find|search|grep|report|check|display|explain|describe|inspect|investigate|read|change|update|edit|adjust|modify|rename|uruchom|pokaż|znajdź|sprawdź|wyjaśnij|opisz|przejrzyj|zmień|zaktualizuj|popraw)\b",
94
+ re.I,
95
+ )
96
+ MULTI_STEP = re.compile(r"\b(?:and|then|plan|design|compare|improve|fix|implement|build|adapt|optimi[sz]\w*|error|failure|fail|bug|debug|diagnos\w*|audit|review|diff|pull request|oraz|następnie|zaplanuj|napraw|wdroż|dostosuj|usprawnij|ulepsz|zaplanuj|zmień|napraw)\b", re.I)
97
+
98
+
99
+ def classify_task(prompt: str) -> tuple[str, str] | None:
100
+ """Classify substantive task intent locally without inferring the Codex UI mode."""
101
+ if not isinstance(prompt, str) or not MIN_TASK_CHARS <= len(prompt) <= 50_000:
102
+ return None
103
+ if len(prompt) <= 100 and SIMPLE_REQUEST.search(prompt) and not MULTI_STEP.search(prompt):
104
+ return None
105
+ task_kind = next((name for name, pattern in TASK_PATTERNS if pattern.search(prompt)), None)
106
+ if task_kind is None:
107
+ return None
108
+ domain = next((name for name, pattern in DOMAIN_PATTERNS if pattern.search(prompt)), "general")
109
+ if domain == "codex" and task_kind not in {"documentation", "debugging", "codex-setup"}:
110
+ domain = next((name for name, pattern in DOMAIN_PATTERNS if name != "codex" and pattern.search(prompt)), "general")
111
+ if task_kind in {"project-setup", "api-design", "history"} and domain == "general":
112
+ domain = "software"
113
+ return task_kind, domain
114
+
115
+
116
+ def _candidate_payload(items: list[dict[str, Any]]) -> list[dict[str, str]]:
117
+ return [
118
+ {
119
+ "id": item["id"],
120
+ "capability": item["capability"],
121
+ "use_when": item["use_when"],
122
+ "avoid_when": item["avoid_when"],
123
+ "availability": item.get("availability", "available"),
124
+ }
125
+ for item in items
126
+ ]
127
+
128
+
129
+ def _balanced_shortlist(items: list[dict[str, Any]], per_kind: int = 3) -> list[dict[str, Any]]:
130
+ """Keep Jev's choice set small and balanced between tools and skills."""
131
+ result: list[dict[str, Any]] = []
132
+ for kind in ("tool", "skill"):
133
+ result.extend(item for item in items if item["kind"] == kind) # preserve curated order
134
+ group = [item for item in result if item["kind"] == kind]
135
+ if len(group) > per_kind:
136
+ kept = {item["id"] for item in group[:per_kind]}
137
+ result = [item for item in result if item["kind"] != kind or item["id"] in kept]
138
+ return result
139
+
140
+
141
+ def _questions(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
142
+ questions: dict[str, dict[str, Any]] = {}
143
+ for kind in ("tool", "skill"):
144
+ group = [item for item in items if item["kind"] == kind]
145
+ if len(group) < 2:
146
+ continue
147
+ questions[kind] = {
148
+ "type": "choice",
149
+ "instructions": f"Which catalogued {kind} should be considered first for this task category, given the use and avoid criteria?",
150
+ "criteria": {item["id"]: item["capability"] for item in group},
151
+ }
152
+ return questions
153
+
154
+
155
+ def _cache_key(event: str, category: str, role: str, domain: str, items: list[dict[str, Any]]) -> str:
156
+ material = ["decisions-v1", catalog_version(), event, category, role, domain,
157
+ configured_model(), MIN_CONFIDENCE,
158
+ sorted((item["id"], item.get("availability"), item.get("capability"),
159
+ item.get("use_when"), item.get("avoid_when")) for item in items)]
160
+ return hashlib.sha256(json.dumps(material, sort_keys=True).encode()).hexdigest()
161
+
162
+
163
+ def _read_cache(key: str, allowed: set[str]) -> list[str] | None:
164
+ try:
165
+ data = json.loads((CACHE_DIR / (key + ".json")).read_text())
166
+ if time.time() - data["created"] > 86_400:
167
+ return None
168
+ selected = data["selected"]
169
+ if isinstance(selected, list) and all(isinstance(value, str) and value in allowed for value in selected):
170
+ return selected
171
+ except (OSError, ValueError, TypeError, KeyError):
172
+ pass
173
+ return None
174
+
175
+
176
+ def _write_cache(key: str, selected: list[str]) -> None:
177
+ try:
178
+ CACHE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
179
+ target = CACHE_DIR / (key + ".json")
180
+ temporary = CACHE_DIR / (key + f".{os.getpid()}.tmp")
181
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
182
+ try:
183
+ with os.fdopen(fd, "w") as stream:
184
+ json.dump({"created": time.time(), "selected": selected}, stream)
185
+ os.replace(temporary, target)
186
+ finally:
187
+ temporary.unlink(missing_ok=True)
188
+ except OSError:
189
+ pass
190
+
191
+
192
+ def _judge(category: str, role: str, domain: str, items: list[dict[str, Any]]) -> list[str] | None:
193
+ questions = _questions(items)
194
+ if not questions:
195
+ return None
196
+ state = {"task_kind": category, "role": role, "domain": domain,
197
+ "candidates": _candidate_payload(items)}
198
+ try:
199
+ answers = DecisionsClient(timeout=JEV_TIMEOUT).decide(state, questions)
200
+ if set(answers) != set(questions):
201
+ return None
202
+ selected: list[str] = []
203
+ for qid, answer in answers.items():
204
+ if not isinstance(answer, dict) or answer.get("type") != "choice":
205
+ return None
206
+ choice = answer.get("choice")
207
+ if not isinstance(choice, str) or choice not in questions[qid]["criteria"]:
208
+ return None
209
+ confidence = answer.get("confidence")
210
+ if (not isinstance(confidence, (int, float)) or isinstance(confidence, bool)
211
+ or not MIN_CONFIDENCE <= confidence <= 1):
212
+ continue
213
+ selected.append(choice)
214
+ return selected or None
215
+ except (DecisionsError, OSError, ValueError, TypeError, KeyError):
216
+ return None
217
+
218
+
219
+ def _context(
220
+ event: str,
221
+ selected: list[str],
222
+ items: list[dict[str, Any]],
223
+ trace: str | None = None,
224
+ selection_source: str = "jev",
225
+ category: str | None = None,
226
+ ) -> dict[str, Any] | None:
227
+ chosen = {item["id"]: item for item in items}
228
+ lines = []
229
+ has_criteria = False
230
+ for identifier in selected:
231
+ item = chosen.get(identifier)
232
+ if item is None:
233
+ return None
234
+ suffix = " (configured locally; confirm it is connected in this session)" if item.get("availability") == "configured" else ""
235
+ if item.get("invocation") == "shell_command":
236
+ lines.append(f"- local command `{identifier}`: {item['capability']} (run through `exec_command`; confirm it is available in this session){suffix}")
237
+ else:
238
+ criteria = ""
239
+ if item["kind"] == "skill":
240
+ use_when, avoid_when = item.get("use_when"), item.get("avoid_when")
241
+ if (isinstance(use_when, str) and isinstance(avoid_when, str)
242
+ and len(use_when) <= 160 and len(avoid_when) <= 160):
243
+ criteria = f" (use when: {use_when}; skip when: {avoid_when})"
244
+ has_criteria = True
245
+ lines.append(f"- {item['kind']} `{identifier}`: {item['capability']}{criteria}{suffix}")
246
+ if not lines:
247
+ return None
248
+
249
+ source_note = (
250
+ "Local unranked fallback; Jev did not select these candidates. "
251
+ if selection_source == "local" else ""
252
+ )
253
+ if event == "UserPromptSubmit":
254
+ prefix = source_note + "Optional tools and skills for this task; validate against the task and actual availability:\n"
255
+ suffix = "\nConfigured MCP entries must be confirmed connected in this session. Inspect task scope first. Read a chosen skill only when its use condition fits; follow required project instructions and tests. If you write a plan, include concise execution recommendations for the primary agent and useful subagents."
256
+ else:
257
+ prefix = source_note + "Optional tools and skills for this agent role; validate them against your actual task and availability:\n"
258
+ suffix = "\nConfigured MCP entries must be confirmed connected in this session. Inspect task scope first. Read a chosen skill only when its use condition fits. Follow the task brief and required project instructions."
259
+ if category == "source-review":
260
+ suffix += " Verify current authoritative local sources and mark missing facts. Keep drafts unsent unless explicitly authorized."
261
+ if category == "package-docs":
262
+ suffix += " For Python package install docs, check project metadata and the existing README. Preserve its exact supported CLI help invocation (such as `python -m package --help`) unless a replacement is verified by running it. Check the documented test command; distinguish pre-existing test failures."
263
+ context = (f"JevCompass advice ID: {trace}\n" if trace else "") + prefix + "\n".join(lines) + suffix
264
+ if len(context) > MAX_CONTEXT_CHARS:
265
+ if has_criteria:
266
+ compact = [{**item, "use_when": None, "avoid_when": None} for item in items]
267
+ return _context(event, selected, compact, trace, selection_source, category)
268
+ return None
269
+ return {"hookSpecificOutput": {"hookEventName": event, "additionalContext": context}}
270
+
271
+
272
+ def _metric(event: str, category: str, status: str, started: float, trace: str | None = None) -> None:
273
+ # No prompt, model response, paths, or memory content are ever logged.
274
+ try:
275
+ LOG_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
276
+ fd = os.open(LOG_PATH, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
277
+ with os.fdopen(fd, "a") as stream:
278
+ record = {"event": event, "category": category, "status": status,
279
+ "duration_ms": round((time.monotonic() - started) * 1000, 2)}
280
+ if trace:
281
+ record["trace"] = trace
282
+ stream.write(json.dumps(record) + "\n")
283
+ except OSError:
284
+ pass
285
+
286
+
287
+ def _load_advice_dependencies() -> None:
288
+ """Bind catalog and decision helpers only after local eligibility succeeds."""
289
+ if "candidates" not in globals() or "catalog_version" not in globals():
290
+ from .catalog import candidates, catalog_version
291
+
292
+ globals().setdefault("candidates", candidates)
293
+ globals().setdefault("catalog_version", catalog_version)
294
+ if not {"DecisionsClient", "DecisionsError", "configured_model"}.issubset(globals()):
295
+ from .decisions import DecisionsClient, DecisionsError, configured_model
296
+
297
+ globals().setdefault("DecisionsClient", DecisionsClient)
298
+ globals().setdefault("DecisionsError", DecisionsError)
299
+ globals().setdefault("configured_model", configured_model)
300
+
301
+
302
+ def select_advice(name: str, category: str, domain: str, role: str, trace: str | None = None) -> dict[str, Any] | None:
303
+ _load_advice_dependencies()
304
+ started = time.monotonic()
305
+ pool = candidates(task_kind=CATALOG_TASKS[category], role="any", domain=domain, limit=20)
306
+ if category == "codex-setup":
307
+ # Office-document tooling is a broad `document` match, not Codex setup guidance.
308
+ pool = [item for item in pool if item["id"] == "openai-docs"]
309
+ # A configured MCP server is not proof that this Codex session exposes it.
310
+ items = _balanced_shortlist([item for item in pool if item.get("availability") != "configured"], per_kind=3)
311
+ if not isinstance(items, list) or not items:
312
+ _metric(name, category, "insufficient-candidates", started, trace)
313
+ return None
314
+ # The built-in shell is a generic capability, not a meaningful singleton
315
+ # recommendation for either a task hook or a role-only subagent hook.
316
+ if len(items) == 1 and items[0]["id"] == "exec_command" and category != "package-docs":
317
+ _metric(name, category, "low-signal-skip", started, trace)
318
+ return None
319
+
320
+ questions = _questions(items)
321
+ singleton_ids = [
322
+ group[0]["id"]
323
+ for kind in ("tool", "skill")
324
+ if len(group := [item for item in items if item["kind"] == kind]) == 1
325
+ ]
326
+ if not questions:
327
+ output = _context(name, singleton_ids, items, trace, selection_source="local", category=category) if singleton_ids else None
328
+ _metric(name, category, "local" if output else "insufficient-candidates", started, trace)
329
+ return output
330
+
331
+ key = _cache_key(name, category, role, domain, items)
332
+ allowed = {item["id"] for item in items}
333
+ choices = _read_cache(key, allowed)
334
+ status = "cache" if choices is not None else "jev"
335
+ if choices is None:
336
+ choices = _judge(category, role, domain, items)
337
+ if choices:
338
+ _write_cache(key, choices)
339
+ else:
340
+ status = "local"
341
+
342
+ if status == "local":
343
+ # If Jev provides no usable choice, show a bounded, explicitly unranked
344
+ # catalog shortlist instead of implying the model chose a winner.
345
+ selected = [item["id"] for item in items]
346
+ else:
347
+ selected = list(dict.fromkeys([*singleton_ids, *(choices or [])]))
348
+
349
+ source = "local" if status == "local" else "jev"
350
+ output = _context(name, selected, items, trace, selection_source=source, category=category) if selected else None
351
+ _metric(name, category, status if output else "skip", started, trace)
352
+ return output
353
+
354
+
355
+ def _spawn_intent(event: dict[str, Any]) -> tuple[str, str, str] | None:
356
+ """Derive only bounded category/domain/role from a pending subagent call locally."""
357
+ if event.get("tool_name") not in SPAWN_TOOL_NAMES:
358
+ return None
359
+ arguments = event.get("tool_input")
360
+ if not isinstance(arguments, dict):
361
+ return None
362
+ message = arguments.get("message")
363
+ title = arguments.get("task_name")
364
+ texts: list[str] = []
365
+ # Current Codex hosts can encode the child message as a single opaque token.
366
+ # Natural-language messages have separated words; never decode or log the token.
367
+ if (isinstance(message, str) and MIN_TASK_CHARS <= len(message) <= SPAWN_TEXT_LIMIT
368
+ and re.search(r"\s", message) and len(re.findall(r"\b\w+\b", message)) >= 5):
369
+ texts.append(message)
370
+ if isinstance(title, str) and MIN_TASK_CHARS <= len(title) <= 160:
371
+ normalized = re.sub(r"[_-]+", " ", title)
372
+ if len(re.findall(r"\b\w+\b", normalized)) >= 2:
373
+ texts.append(normalized)
374
+ parsed = next((result for text in texts if (result := classify_task(text)) is not None), None)
375
+ if parsed is None:
376
+ return None
377
+ category, domain = parsed
378
+ if domain == "general" and category in {"coding", "debugging", "testing", "infrastructure", "codebase"}:
379
+ domain = "software"
380
+ role = arguments.get("agent_type")
381
+ return category, domain, role if role in {"explorer", "worker"} else "subagent"
382
+
383
+
384
+ def evaluate(event: dict[str, Any], trace: str | None = None) -> dict[str, Any] | None:
385
+ name = event.get("hook_event_name")
386
+ started = time.monotonic()
387
+ if name == "UserPromptSubmit":
388
+ parsed = classify_task(event.get("prompt"))
389
+ if parsed is None:
390
+ if trace:
391
+ _metric(name, "none", "classification-skip", started, trace)
392
+ return None
393
+ category, domain = parsed
394
+ if domain == "general" and category in {"coding", "debugging", "testing", "infrastructure", "codebase"}:
395
+ domain = "software"
396
+ role = "primary"
397
+ elif name == "SubagentStart":
398
+ role = event.get("agent_type")
399
+ if role not in ROLE_CATEGORIES:
400
+ if trace:
401
+ _metric(name, "none", "role-skip", started, trace)
402
+ return None
403
+ category, domain = ROLE_CATEGORIES[role]
404
+ elif name == "PreToolUse":
405
+ intent = _spawn_intent(event)
406
+ if intent is None:
407
+ if trace:
408
+ _metric(name, "none", "classification-skip", started, trace)
409
+ return None
410
+ category, domain, role = intent
411
+ else:
412
+ return None
413
+ return select_advice(name, category, domain, role, trace)
414
+
415
+
416
+ def hook_main() -> int:
417
+ """Read one Codex hook event and always exit without blocking it."""
418
+ try:
419
+ raw = sys.stdin.buffer.read(MAX_EVENT_BYTES + 1)
420
+ if len(raw) > MAX_EVENT_BYTES:
421
+ return 0
422
+ event = json.loads(raw)
423
+ if isinstance(event, dict):
424
+ diagnostic = os.environ.get("JEV_ADVISOR_DIAGNOSTIC") == "1"
425
+ name = event.get("hook_event_name")
426
+ eligible = ((name == "UserPromptSubmit" and classify_task(event.get("prompt")) is not None)
427
+ or (name == "SubagentStart" and event.get("agent_type") in ROLE_CATEGORIES)
428
+ or (name == "PreToolUse" and _spawn_intent(event) is not None))
429
+ trace = secrets.token_hex(4) if eligible or diagnostic else None
430
+ if diagnostic:
431
+ mode = event.get("permission_mode")
432
+ collaboration = event.get("collaboration_mode")
433
+ _metric(name if name in {"UserPromptSubmit", "SubagentStart", "PreToolUse"} else "unknown",
434
+ mode if mode in {"default", "plan", "acceptEdits", "dontAsk", "bypassPermissions"} else "unknown",
435
+ "collab-plan" if collaboration == "plan" else "collab-unavailable", time.monotonic(), trace)
436
+ output = evaluate(event, trace)
437
+ if output:
438
+ print(json.dumps(output, ensure_ascii=False))
439
+ except (OSError, ValueError, TypeError, KeyError):
440
+ pass
441
+ return 0
442
+
443
+
444
+ def hook_command() -> str:
445
+ """Return a shell-safe hook command bound to this Python installation."""
446
+ import shlex
447
+ return shlex.join([sys.executable, "-m", "jevcompass", "hook"])