agent-coach-cli 0.1.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.
agentcoach/judge.py ADDED
@@ -0,0 +1,328 @@
1
+ """LLM judge: asks the user's own Claude Code or Codex CLI to grade a session.
2
+
3
+ No API key is involved; we shell out to `claude -p` / `codex exec` so the
4
+ user's existing subscription and login are used. Transcripts are redacted
5
+ before they are sent, and the judge runs with no tools in a scratch folder
6
+ whose transcripts collect.py ignores.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ from concurrent.futures import ThreadPoolExecutor, as_completed
16
+
17
+ from .collect import Session
18
+ from .paths import judge_workdir
19
+ from .redact import redact
20
+
21
+ MAX_TURN_CHARS = 1500
22
+ MAX_SESSION_CHARS = 40000 # misses need the whole session, not just its opening
23
+
24
+
25
+ def render_transcript(s: Session) -> str:
26
+ lines = [f"source: {s.source} project: {s.project} turns: {len(s.turns)}"]
27
+ budget = MAX_SESSION_CHARS
28
+ for t in s.turns:
29
+ txt = redact(t.text)
30
+ if len(txt) > MAX_TURN_CHARS:
31
+ txt = txt[:MAX_TURN_CHARS] + f"… [+{len(txt) - MAX_TURN_CHARS} chars]"
32
+ img = f" [+{t.images} image]" if t.images else ""
33
+ block = f"\n### USER turn {t.index}{img}\n{txt}\n"
34
+ if t.agent_after:
35
+ block += f"[agent → {redact(t.agent_after)}]\n"
36
+ if len(block) > budget:
37
+ lines.append(f"\n… {len(s.turns) - t.index + 1} more turns omitted")
38
+ break
39
+ budget -= len(block)
40
+ lines.append(block)
41
+ return "".join(lines)
42
+
43
+
44
+ CAUSES = ("prompt_gap", "agent_error", "changed_mind", "external")
45
+ SIGNALS = ("correction", "repeat", "redo", "mishap", "ask_back", "frustration")
46
+ MISS_ITEM = {
47
+ "type": "object",
48
+ "properties": {
49
+ "turn": {"type": "integer"},
50
+ "signal_turn": {"type": "integer"},
51
+ "signal": {"type": "string", "enum": list(SIGNALS)},
52
+ "cause": {"type": "string", "enum": list(CAUSES)},
53
+ "context_was_enough": {"type": "boolean"},
54
+ "original": {"type": "string"},
55
+ "what_went_wrong": {"type": "string"},
56
+ "better": {"type": "string"},
57
+ "why": {"type": "string"},
58
+ },
59
+ "required": ["turn", "signal_turn", "signal", "cause", "context_was_enough", "original",
60
+ "what_went_wrong", "better", "why"],
61
+ "additionalProperties": False,
62
+ }
63
+
64
+
65
+ def output_schema(rubric: dict) -> dict:
66
+ dim_props = {
67
+ d["id"]: {
68
+ "type": "object",
69
+ "properties": {
70
+ "score": {"type": ["integer", "null"], "enum": [0, 1, 2, None]},
71
+ "evidence_turn": {"type": ["integer", "null"]},
72
+ "note": {"type": "string"},
73
+ },
74
+ "required": ["score", "evidence_turn", "note"],
75
+ "additionalProperties": False,
76
+ }
77
+ for d in rubric["dimensions"]
78
+ }
79
+ return {
80
+ "type": "object",
81
+ "properties": {
82
+ "task_summary": {"type": "string"},
83
+ "dimensions": {"type": "object", "properties": dim_props,
84
+ "required": list(dim_props), "additionalProperties": False},
85
+ "coaching": {
86
+ "type": "array",
87
+ "maxItems": 2,
88
+ "items": {
89
+ "type": "object",
90
+ "properties": {
91
+ "dimension": {"type": "string", "enum": list(dim_props)},
92
+ "turn": {"type": "integer"},
93
+ "original": {"type": "string"},
94
+ "better": {"type": "string"},
95
+ "why": {"type": "string"},
96
+ },
97
+ "required": ["dimension", "turn", "original", "better", "why"],
98
+ "additionalProperties": False,
99
+ },
100
+ },
101
+ "misses": {"type": "array", "maxItems": 8, "items": MISS_ITEM},
102
+ },
103
+ "required": ["task_summary", "dimensions", "coaching", "misses"],
104
+ "additionalProperties": False,
105
+ }
106
+
107
+
108
+ def build_prompt(s: Session, rubric: dict, lang: str = "ko") -> str:
109
+ dims = []
110
+ for d in rubric["dimensions"]:
111
+ guide = " / ".join(f'{g["section"]}: {g["quote"]}' for g in d["guide"])
112
+ dims.append(
113
+ f'- id: {d["id"]} ({d["name"]})\n what good looks like: {d["summary"]}\n'
114
+ f' look for: {d["look_for"]}\n applies when: {d["applies_when"]}\n guide: {guide}'
115
+ )
116
+ out_lang = "Korean" if lang == "ko" else "English"
117
+ return f"""You are a coach for people who direct AI coding agents (Claude Code, Codex).
118
+ Grade ONLY the human's instructions in the transcript below against the official-guide rubric.
119
+ The agent's actions appear as one-line [agent → ...] summaries; use them to see consequences
120
+ (e.g. the user had to correct something they could have said up front), not to grade the agent.
121
+
122
+ Scale per dimension: 0 = missed what the guide recommends, 1 = partial or only after correction,
123
+ 2 = did it from the start, null = not applicable to this conversation. Be strict but fair:
124
+ short casual chats legitimately make most dimensions null. Do not reward length for its own sake.
125
+ evidence_turn = the USER turn number that best shows the score (null if none).
126
+ note = one short sentence in {out_lang} explaining the score, citing what the user wrote or omitted.
127
+
128
+ coaching = up to 2 of the most valuable rewrites, for the dimensions where the user lost the most.
129
+ "original" = a short verbatim excerpt (<=200 chars) of the user's real turn.
130
+ "better" = how that same turn should have been written, in the user's own language and tone,
131
+ using only facts visible in the transcript (no invented file names; use <placeholders> if needed).
132
+ "why" = one sentence in {out_lang} tying the rewrite to the guide.
133
+ If nothing is worth coaching, return an empty list.
134
+ task_summary = one short line in {out_lang} describing what the session was about.
135
+
136
+ misses = moments where the user did NOT get what they wanted from a turn (at most 8, most costly first).
137
+ Long conversations build context, so a short instruction like "ok do it" / "응 해줘" is fine when context already made
138
+ the intent clear; never treat brevity alone as a miss. Evidence comes from what happened next: the user
139
+ corrected the result, repeated an earlier instruction, asked to redo/undo, reported damage or an unwanted
140
+ action, showed frustration, or the agent had to ask back for missing info.
141
+ - turn = the USER turn whose result missed; signal_turn = the USER turn that shows it.
142
+ - signal: correction | repeat | redo | mishap | ask_back | frustration
143
+ - context_was_enough: true if a careful agent could have inferred the intent from the conversation so far.
144
+ - cause: prompt_gap = the intent depended on something only the user knew (a preference, constraint,
145
+ number, scope, where to stop, which item) that the turn didn't say AND context didn't cover;
146
+ agent_error = context/instruction was enough and the agent misread, forgot or ignored it;
147
+ changed_mind = the user decided something new after seeing the result; external = tool/site/login limits.
148
+ - original: short verbatim excerpt (<=160 chars) of the missed turn.
149
+ - what_went_wrong: one sentence in {out_lang}.
150
+ - better: ONLY for prompt_gap, the same turn in the user's own tone adding just the missing piece
151
+ (keep it short; don't restate what context already covered). Empty string for other causes.
152
+ - why: one sentence in {out_lang}: for prompt_gap the single missing piece; otherwise why it was not the
153
+ user's prompt problem.
154
+ Never copy personal identifiers (names, addresses, ID/phone/card numbers) into any field; write <address>, <name>.
155
+
156
+ Rubric:
157
+ {chr(10).join(dims)}
158
+
159
+ Transcript:
160
+ <<<
161
+ {render_transcript(s)}
162
+ >>>
163
+
164
+ Reply with JSON only, matching the provided schema."""
165
+
166
+
167
+ def _extract_json(text: str) -> dict:
168
+ text = text.strip()
169
+ if text.startswith("```"):
170
+ text = text.strip("`")
171
+ text = text[text.find("{"):]
172
+ start, end = text.find("{"), text.rfind("}")
173
+ return json.loads(text[start:end + 1])
174
+
175
+
176
+ def _run_claude(prompt: str, schema: dict, model: str | None, timeout: int) -> dict:
177
+ exe = shutil.which("claude")
178
+ if not exe:
179
+ raise RuntimeError("claude CLI not found on PATH")
180
+ cmd = [exe, "-p", "--output-format", "json", "--no-session-persistence",
181
+ "--tools", "", "--json-schema", json.dumps(schema)]
182
+ if model:
183
+ cmd += ["--model", model]
184
+ r = subprocess.run(cmd, input=prompt, capture_output=True, text=True, encoding="utf-8",
185
+ cwd=judge_workdir(), timeout=timeout)
186
+ if r.returncode != 0:
187
+ raise RuntimeError(f"claude exited {r.returncode}: {(r.stderr or r.stdout)[-400:]}")
188
+ env = json.loads(r.stdout)
189
+ if env.get("is_error"):
190
+ raise RuntimeError(f"claude error: {str(env.get('result'))[:400]}")
191
+ if isinstance(env.get("structured_output"), dict):
192
+ return env["structured_output"]
193
+ return _extract_json(env.get("result", ""))
194
+
195
+
196
+ def _run_codex(prompt: str, schema: dict, model: str | None, timeout: int) -> dict:
197
+ exe = shutil.which("codex")
198
+ if not exe:
199
+ raise RuntimeError("codex CLI not found on PATH")
200
+ with tempfile.TemporaryDirectory(dir=judge_workdir()) as tmp:
201
+ schema_path = os.path.join(tmp, "schema.json")
202
+ out_path = os.path.join(tmp, "out.json")
203
+ with open(schema_path, "w", encoding="utf-8") as fh:
204
+ json.dump(_codex_strict(schema), fh)
205
+ cmd = [exe, "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only",
206
+ "--output-schema", schema_path, "-o", out_path, "-"]
207
+ if model:
208
+ cmd[2:2] = ["-m", model]
209
+ r = subprocess.run(cmd, input=prompt, capture_output=True, text=True, encoding="utf-8",
210
+ cwd=tmp, timeout=timeout)
211
+ if r.returncode != 0 or not os.path.exists(out_path):
212
+ raise RuntimeError(f"codex exited {r.returncode}: {(r.stderr or r.stdout)[-400:]}")
213
+ with open(out_path, encoding="utf-8") as fh:
214
+ return _extract_json(fh.read())
215
+
216
+
217
+ def _codex_strict(schema: dict) -> dict:
218
+ """OpenAI structured outputs reject `enum` containing null mixed with type lists; simplify."""
219
+ s = json.loads(json.dumps(schema))
220
+
221
+ def walk(node):
222
+ if isinstance(node, dict):
223
+ if node.get("enum") == [0, 1, 2, None]:
224
+ node.pop("enum")
225
+ for v in node.values():
226
+ walk(v)
227
+ elif isinstance(node, list):
228
+ for v in node:
229
+ walk(v)
230
+ walk(s)
231
+ return s
232
+
233
+
234
+ def judge_session(s: Session, rubric: dict, backend: str, model: str | None = None,
235
+ lang: str = "ko", timeout: int = 600) -> dict:
236
+ schema = output_schema(rubric)
237
+ prompt = build_prompt(s, rubric, lang)
238
+ run = _run_claude if backend == "claude" else _run_codex
239
+ res = run(prompt, schema, model, timeout)
240
+ res["judge"] = f"{backend}:{model or 'default'}"
241
+ _clean(res, rubric)
242
+ return res
243
+
244
+
245
+ def _clean(res: dict, rubric: dict) -> None:
246
+ dims = res.setdefault("dimensions", {})
247
+ for d in rubric["dimensions"]:
248
+ v = dims.get(d["id"]) or {}
249
+ sc = v.get("score")
250
+ v["score"] = sc if sc in (0, 1, 2) else None
251
+ v.setdefault("evidence_turn", None)
252
+ v.setdefault("note", "")
253
+ dims[d["id"]] = v
254
+ res["coaching"] = [c for c in res.get("coaching") or [] if c.get("dimension") in dims][:2]
255
+ misses = []
256
+ for m in res.get("misses") or []:
257
+ if m.get("cause") not in CAUSES:
258
+ continue
259
+ if m["cause"] != "prompt_gap":
260
+ m["better"] = "" # only the user's own gaps get a rewrite
261
+ misses.append(m)
262
+ res["misses"] = misses[:8]
263
+
264
+
265
+ def judge_many(sessions: list[Session], rubric: dict, backend: str, model: str | None,
266
+ lang: str, workers: int = 3, on_done=None) -> dict[str, dict]:
267
+ results: dict[str, dict] = {}
268
+ with ThreadPoolExecutor(max_workers=workers) as ex:
269
+ futs = {ex.submit(judge_session, s, rubric, backend, model, lang): s for s in sessions}
270
+ for f in as_completed(futs):
271
+ s = futs[f]
272
+ try:
273
+ results[s.id] = f.result()
274
+ except Exception as e: # keep going; the session falls back to heuristics
275
+ results[s.id] = {"error": str(e)}
276
+ if on_done:
277
+ on_done(s, results[s.id])
278
+ return results
279
+
280
+
281
+ GAP_SCHEMA = {
282
+ "type": "object",
283
+ "properties": {"themes": {"type": "array", "maxItems": 6, "items": {
284
+ "type": "object",
285
+ "properties": {
286
+ "title": {"type": "string"},
287
+ "items": {"type": "array", "items": {"type": "integer"}},
288
+ "rule": {"type": "string"},
289
+ "where": {"type": "string", "enum": ["CLAUDE.md/AGENTS.md", "memory", "skill", "hook", "prompt"]},
290
+ },
291
+ "required": ["title", "items", "rule", "where"],
292
+ "additionalProperties": False}}},
293
+ "required": ["themes"],
294
+ "additionalProperties": False,
295
+ }
296
+
297
+
298
+ def summarize_gaps(gaps: list[dict], backend: str, model: str | None, lang: str = "ko") -> list[dict]:
299
+ """Group prompt gaps that recur across conversations and propose one durable rule per group.
300
+
301
+ A gap that keeps coming back ("always use browser X", "affiliation is 'individual'") shouldn't be fixed by typing it
302
+ again every time; it belongs in CLAUDE.md/AGENTS.md, memory, a skill or a hook.
303
+ """
304
+ if len(gaps) < 2:
305
+ return []
306
+ out_lang = "Korean" if lang == "ko" else "English"
307
+ lines = chr(10).join(f"[{i}] ({g['project']}) missing: {g['why']} | turn: {g['original'][:120]}" for i, g in enumerate(gaps))
308
+ prompt = f"""Below are moments where a person's instruction to an AI agent missed one piece of information
309
+ only they knew. Group the ones that are the SAME recurring piece of knowledge or preference (it shows up in
310
+ 2+ items, ideally across different projects). Ignore one-off facts (a single date, a single count).
311
+ For each group: title (short, {out_lang}), items (the [index] numbers), rule (one line the person could put
312
+ in a persistent instruction file so they never have to say it again, {out_lang}, no personal identifiers),
313
+ where (best home: CLAUDE.md/AGENTS.md for project rules, memory for personal facts/preferences, skill for
314
+ a repeatable procedure, hook for something that must never be skipped, prompt if it truly varies per task).
315
+ Return an empty list if nothing recurs.
316
+
317
+ {lines}"""
318
+ run = _run_claude if backend == "claude" else _run_codex
319
+ try:
320
+ res = run(prompt, GAP_SCHEMA, model, 300)
321
+ except Exception:
322
+ return []
323
+ themes = []
324
+ for t in res.get("themes") or []:
325
+ idx = [i for i in t.get("items", []) if isinstance(i, int) and 0 <= i < len(gaps)]
326
+ if len(idx) >= 2:
327
+ themes.append({**t, "items": idx})
328
+ return themes
agentcoach/paths.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def coach_home() -> Path:
8
+ """Where runs, reports and the judge's scratch folder live (never inside a repo)."""
9
+ p = Path(os.environ.get("AGENT_COACH_HOME", Path.home() / ".agent-coach"))
10
+ p.mkdir(parents=True, exist_ok=True)
11
+ return p
12
+
13
+
14
+ def history_dir() -> Path:
15
+ p = coach_home() / "history"
16
+ p.mkdir(exist_ok=True)
17
+ return p
18
+
19
+
20
+ def judge_workdir() -> Path:
21
+ # Folder name contains "agent-coach-judge" so collect.py skips any transcript written here.
22
+ p = coach_home() / "agent-coach-judge"
23
+ p.mkdir(exist_ok=True)
24
+ return p
agentcoach/redact.py ADDED
@@ -0,0 +1,52 @@
1
+ """Mask secrets and contact details before anything leaves the parser.
2
+
3
+ Built-in patterns cover keys, tokens, emails, phone/ID numbers and Korean street
4
+ addresses. Names can't be detected reliably, so list your own (name, romanized
5
+ name, company, address fragments) one per line in ~/.agent-coach/redact.txt.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ _PATTERNS = [
15
+ (re.compile(r"sk-(?:ant-|proj-)?[A-Za-z0-9_\-]{16,}"), "[API_KEY]"),
16
+ (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "[GITHUB_TOKEN]"),
17
+ (re.compile(r"AKIA[0-9A-Z]{16}"), "[AWS_KEY]"),
18
+ (re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}"), "[SLACK_TOKEN]"),
19
+ (re.compile(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}"), "[JWT]"),
20
+ (re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"), "[EMAIL]"),
21
+ (re.compile(r"\b\d{6}[- ]?[1-4]\d{6}\b"), "[ID_NUMBER]"),
22
+ (re.compile(r"\b(?:\d{4}[- ]){3}\d{4}\b"), "[CARD_NUMBER]"),
23
+ (re.compile(r"01[016789][-. ]?\d{3,4}[-. ]?\d{4}"), "[PHONE]"),
24
+ # Korean road-name address, optionally followed by building/unit ("행복로 12, 101동 202호")
25
+ (re.compile(r"[가-힣0-9]{1,20}(?:로|길)\s?\d{1,4}(?:-\d{1,4})?(?![0-9가-힣])(?:\s*,?\s*\d{1,4}동)?(?:\s*\d{1,5}호)?"), "[ADDRESS]"),
26
+ (re.compile(r"\b\d{1,4}동\s?\d{1,5}호\b"), "[ADDRESS]"),
27
+ (re.compile(r"(?i)(password|passwd|pwd|secret|token|api[_-]?key|비밀번호|비번)\s*[:=]\s*\S+"), r"\1=[REDACTED]"),
28
+ ]
29
+
30
+
31
+ def _custom_file() -> Path:
32
+ return Path(os.environ.get("AGENT_COACH_HOME", Path.home() / ".agent-coach")) / "redact.txt"
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def _custom_terms() -> tuple[str, ...]:
37
+ try:
38
+ lines = _custom_file().read_text(encoding="utf-8").splitlines()
39
+ except OSError:
40
+ return ()
41
+ terms = {ln.strip() for ln in lines if ln.strip() and not ln.lstrip().startswith("#")}
42
+ return tuple(sorted(terms, key=len, reverse=True))
43
+
44
+
45
+ def redact(text: str) -> str:
46
+ if not text:
47
+ return text
48
+ for term in _custom_terms():
49
+ text = re.sub(re.escape(term), "[PRIVATE]", text, flags=re.I)
50
+ for rx, repl in _PATTERNS:
51
+ text = rx.sub(repl, text)
52
+ return text