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 +3 -0
- jevcompass/__main__.py +10 -0
- jevcompass/advisor.py +447 -0
- jevcompass/catalog.py +268 -0
- jevcompass/catalog_data.json +26 -0
- jevcompass/cli.py +344 -0
- jevcompass/credentials.py +215 -0
- jevcompass/decisions.py +94 -0
- jevcompass/installer.py +166 -0
- jevcompass/paths.py +12 -0
- jevcompass-0.1.14.dist-info/METADATA +145 -0
- jevcompass-0.1.14.dist-info/RECORD +16 -0
- jevcompass-0.1.14.dist-info/WHEEL +5 -0
- jevcompass-0.1.14.dist-info/entry_points.txt +2 -0
- jevcompass-0.1.14.dist-info/licenses/LICENSE +202 -0
- jevcompass-0.1.14.dist-info/top_level.txt +1 -0
jevcompass/__init__.py
ADDED
jevcompass/__main__.py
ADDED
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"])
|