methodproof 0.8.3__tar.gz → 0.8.4__tar.gz
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.
- {methodproof-0.8.3 → methodproof-0.8.4}/PKG-INFO +1 -1
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/__init__.py +1 -1
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/agents/watcher.py +9 -2
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/claude_code.py +58 -9
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/claude_code.sh +65 -4
- methodproof-0.8.4/methodproof/hooks/model_cache.py +164 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/pyproject.toml +1 -1
- methodproof-0.8.4/tests/test_model_cache.py +278 -0
- methodproof-0.8.4/tests/test_watcher_ignore.py +63 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/uv.lock +1 -1
- {methodproof-0.8.3 → methodproof-0.8.4}/.github/workflows/ci.yml +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/.gitignore +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/CHANGELOG.md +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/LICENSE +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/README.md +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/__main__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/_daemon.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/agents/base.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/agents/music.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/agents/terminal.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/analysis.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/binding.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/bip39.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/bridge.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/cli.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/config.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/crypto.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/e2e.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/graph.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hook.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/install.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/integrity.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/kdf.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/keychain.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/live.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/lock.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/mcp.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/migrate_db.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/proxy.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/repos.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/store.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/sync.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/__init__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/consent.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/init.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/log.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/login_success.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/review.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/start.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/status.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/tui/theme.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/viewer.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/methodproof/wordlist.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/test_windows_compat.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/__init__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/conftest.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_analysis.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_cli_auth.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_cli_config.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_cli_helpers.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_cli_session.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_cli_share.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_cli_start.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_cli_update.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_e2e_integration.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_graph.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_hooks.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_live.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_profiles.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_repos.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_security.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_store.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_sync.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_viewer.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.4}/tests/test_wrappers.py +0 -0
|
@@ -40,8 +40,15 @@ IGNORE_PATTERNS = re.compile(
|
|
|
40
40
|
r"|\.build/|DerivedData/|Pods/"
|
|
41
41
|
# Build output / artifacts
|
|
42
42
|
r"|dist/|build/|\.output/"
|
|
43
|
-
# Logs and locks
|
|
44
|
-
|
|
43
|
+
# Logs and locks — runtime log output, never engineering source.
|
|
44
|
+
# ``/logs/`` excludes any file under a ``logs/`` directory regardless
|
|
45
|
+
# of extension. The prior ``\.log$`` check was too narrow — session
|
|
46
|
+
# 8c21 had 15,269 file_edit events captured on
|
|
47
|
+
# ``methodproof-platform/logs/methodproof-platform.jsonl`` (the
|
|
48
|
+
# platform's own runtime log, NOT ``.log`` extension) which polluted
|
|
49
|
+
# both the thread and step distributions. Any project with a
|
|
50
|
+
# ``logs/`` subdirectory for runtime output inherits the exclusion.
|
|
51
|
+
r"|/logs/|\.lock$|\.log$)"
|
|
45
52
|
)
|
|
46
53
|
|
|
47
54
|
|
|
@@ -16,6 +16,22 @@ except ImportError:
|
|
|
16
16
|
analyze_prompt = lambda _: {}
|
|
17
17
|
compose_summary = lambda _: ""
|
|
18
18
|
|
|
19
|
+
try:
|
|
20
|
+
from methodproof.hooks import model_cache
|
|
21
|
+
except ImportError:
|
|
22
|
+
model_cache = None # type: ignore[assignment]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _current_model(session_id: str) -> str | None:
|
|
26
|
+
"""Return the most recently-seen model for this Claude session, from
|
|
27
|
+
the per-session cache. None if cache unavailable or no prior update."""
|
|
28
|
+
if model_cache is None or not session_id:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return model_cache.get_model(session_id)
|
|
32
|
+
except Exception:
|
|
33
|
+
return None
|
|
34
|
+
|
|
19
35
|
def _extract_result_text(response) -> str:
|
|
20
36
|
"""Extract plain text from tool_response regardless of shape.
|
|
21
37
|
|
|
@@ -92,25 +108,34 @@ def _tool_input_preview(d: dict) -> str:
|
|
|
92
108
|
return json.dumps(inp)[:300] if inp else ""
|
|
93
109
|
|
|
94
110
|
|
|
111
|
+
def _with_model(meta: dict, d: dict) -> dict:
|
|
112
|
+
"""Attach the session's currently-cached model to a metadata dict.
|
|
113
|
+
No-op when session_id is missing or cache has no entry."""
|
|
114
|
+
model = _current_model(d.get("session_id") or "")
|
|
115
|
+
if model:
|
|
116
|
+
meta["model"] = model
|
|
117
|
+
return meta
|
|
118
|
+
|
|
119
|
+
|
|
95
120
|
_META_EXTRACTORS = {
|
|
96
|
-
"UserPromptSubmit": lambda d: {
|
|
121
|
+
"UserPromptSubmit": lambda d: _with_model({
|
|
97
122
|
"tool": _TOOL,
|
|
98
123
|
"prompt_text": d.get("prompt") or "",
|
|
99
124
|
"prompt_preview": _build_prompt_meta(d.get("prompt") or "").get("prompt_summary", ""),
|
|
100
125
|
"prompt_length": len(d.get("prompt") or ""),
|
|
101
|
-
},
|
|
102
|
-
"PreToolUse": lambda d: {
|
|
126
|
+
}, d),
|
|
127
|
+
"PreToolUse": lambda d: _with_model({
|
|
103
128
|
"tool": _TOOL, "tool_name": d.get("tool_name", "unknown"),
|
|
104
129
|
"tool_input": d.get("tool_input") or {},
|
|
105
130
|
"tool_input_preview": _tool_input_preview(d),
|
|
106
|
-
},
|
|
107
|
-
"PostToolUse": lambda d: {
|
|
131
|
+
}, d),
|
|
132
|
+
"PostToolUse": lambda d: _with_model({
|
|
108
133
|
"tool": _TOOL, "tool_name": d.get("tool_name", "unknown"), "success": True,
|
|
109
134
|
"tool_input": d.get("tool_input") or {},
|
|
110
135
|
"tool_response": d.get("tool_response") or {},
|
|
111
136
|
"tool_input_preview": _tool_input_preview(d),
|
|
112
137
|
"result_preview": _extract_result_text(d.get("tool_response")),
|
|
113
|
-
},
|
|
138
|
+
}, d),
|
|
114
139
|
"PostToolUseFailure": lambda d: {
|
|
115
140
|
"tool": _TOOL, "tool_name": d.get("tool_name", "unknown"),
|
|
116
141
|
"success": False, "is_interrupt": d.get("is_interrupt", False),
|
|
@@ -124,9 +149,9 @@ _META_EXTRACTORS = {
|
|
|
124
149
|
},
|
|
125
150
|
"TaskCreated": lambda d: {"tool": _TOOL, "task_id": d.get("task_id", ""), "subject": d.get("task_subject", "")},
|
|
126
151
|
"TaskCompleted": lambda d: {"tool": _TOOL, "task_id": d.get("task_id", "")},
|
|
127
|
-
"SessionStart": lambda d: {"tool": _TOOL, "session_id": d.get("session_id", ""), "cwd": d.get("cwd", "")},
|
|
152
|
+
"SessionStart": lambda d: _with_model({"tool": _TOOL, "session_id": d.get("session_id", ""), "cwd": d.get("cwd", "")}, d),
|
|
128
153
|
"SessionEnd": lambda d: {"tool": _TOOL, "session_id": d.get("session_id", "")},
|
|
129
|
-
"Stop": lambda d: {"tool": _TOOL},
|
|
154
|
+
"Stop": lambda d: _with_model({"tool": _TOOL}, d),
|
|
130
155
|
"StopFailure": lambda d: {"tool": _TOOL, "error": d.get("error", "")},
|
|
131
156
|
"CwdChanged": lambda d: {
|
|
132
157
|
"tool": _TOOL, "cwd": d.get("cwd", ""),
|
|
@@ -175,6 +200,24 @@ def main() -> None:
|
|
|
175
200
|
return
|
|
176
201
|
|
|
177
202
|
event = data.get("hook_event_name", "unknown")
|
|
203
|
+
session_id = data.get("session_id") or ""
|
|
204
|
+
transcript_path = data.get("transcript_path", "")
|
|
205
|
+
|
|
206
|
+
# Refresh the per-session model cache before running the metadata extractor
|
|
207
|
+
# so `_with_model` sees the freshest value. We include PreToolUse because
|
|
208
|
+
# the first turn's UserPromptSubmit fires before any assistant message is
|
|
209
|
+
# in the transcript — without a PreToolUse refresh, every tool event in
|
|
210
|
+
# that first turn lands with no `model` attribution. PostToolUse is left
|
|
211
|
+
# out: once PreToolUse has refreshed, the cache is warm for the rest of
|
|
212
|
+
# the turn, and PostToolUse would duplicate the transcript read.
|
|
213
|
+
if model_cache is not None and transcript_path and session_id and event in (
|
|
214
|
+
"UserPromptSubmit", "SessionStart", "Stop", "PreToolUse",
|
|
215
|
+
):
|
|
216
|
+
try:
|
|
217
|
+
model_cache.update_from_transcript(session_id, transcript_path)
|
|
218
|
+
except Exception:
|
|
219
|
+
pass # Cache is best-effort; hook must never raise.
|
|
220
|
+
|
|
178
221
|
etype = _TYPE_MAP.get(event, "claude_code_event")
|
|
179
222
|
extractor = _META_EXTRACTORS.get(event)
|
|
180
223
|
meta = extractor(data) if extractor else {"tool": _TOOL, "event": event}
|
|
@@ -183,7 +226,6 @@ def main() -> None:
|
|
|
183
226
|
events_out = [{"type": etype, "timestamp": ts, "metadata": meta}]
|
|
184
227
|
|
|
185
228
|
# On Stop, grep transcript for recap (journal mode only)
|
|
186
|
-
transcript_path = data.get("transcript_path", "")
|
|
187
229
|
if event == "Stop" and transcript_path:
|
|
188
230
|
recap = _extract_recap(transcript_path)
|
|
189
231
|
if recap:
|
|
@@ -193,6 +235,13 @@ def main() -> None:
|
|
|
193
235
|
"metadata": {"tool": _TOOL, "recap": recap[:2000]},
|
|
194
236
|
})
|
|
195
237
|
|
|
238
|
+
# Clean up the cache entry on session end so the file stays bounded.
|
|
239
|
+
if model_cache is not None and event == "SessionEnd" and session_id:
|
|
240
|
+
try:
|
|
241
|
+
model_cache.clear_session(session_id)
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
|
|
196
245
|
payload = json.dumps({"events": events_out}).encode()
|
|
197
246
|
req = urllib.request.Request(
|
|
198
247
|
"http://localhost:9877/events", data=payload,
|
|
@@ -8,6 +8,39 @@
|
|
|
8
8
|
|
|
9
9
|
INPUT=$(cat)
|
|
10
10
|
|
|
11
|
+
# Model cache: per-Claude-session model attribution.
|
|
12
|
+
# The transcript JSONL is the only place Claude Code surfaces the active
|
|
13
|
+
# model. Re-reading it on every PreToolUse is too expensive, so we refresh
|
|
14
|
+
# a cache at the cheap once-per-turn waypoints (SessionStart / Stop — and
|
|
15
|
+
# UserPromptSubmit which delegates to the Python hook that updates it too)
|
|
16
|
+
# and read it via a cheap jq lookup on tool events.
|
|
17
|
+
_MP_MODEL_CACHE="${HOME}/.methodproof/hook_state/models.json"
|
|
18
|
+
|
|
19
|
+
# Read the current model for a session. Fast path — no Python subprocess.
|
|
20
|
+
_mp_read_model() {
|
|
21
|
+
local sess="$1"
|
|
22
|
+
[ -z "$sess" ] || [ ! -f "$_MP_MODEL_CACHE" ] && return
|
|
23
|
+
command -v jq >/dev/null 2>&1 || return
|
|
24
|
+
jq -r --arg s "$sess" '.[$s].model // empty' "$_MP_MODEL_CACHE" 2>/dev/null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Refresh the cache by shelling out to the Python module (handles JSON
|
|
28
|
+
# safely + atomic write). Rare — called on SessionStart / Stop only.
|
|
29
|
+
_mp_update_model() {
|
|
30
|
+
local sess="$1" transcript="$2"
|
|
31
|
+
[ -z "$sess" ] || [ -z "$transcript" ] && return
|
|
32
|
+
command -v python3 >/dev/null 2>&1 || return
|
|
33
|
+
python3 -m methodproof.hooks.model_cache update "$sess" "$transcript" \
|
|
34
|
+
>/dev/null 2>&1 || true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_mp_clear_model() {
|
|
38
|
+
local sess="$1"
|
|
39
|
+
[ -z "$sess" ] && return
|
|
40
|
+
command -v python3 >/dev/null 2>&1 || return
|
|
41
|
+
python3 -m methodproof.hooks.model_cache clear "$sess" >/dev/null 2>&1 || true
|
|
42
|
+
}
|
|
43
|
+
|
|
11
44
|
if command -v jq >/dev/null 2>&1; then
|
|
12
45
|
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"' 2>/dev/null || echo "unknown")
|
|
13
46
|
else
|
|
@@ -25,6 +58,24 @@ fi
|
|
|
25
58
|
|
|
26
59
|
# Build event JSON — use jq if available, else minimal Python
|
|
27
60
|
if command -v jq >/dev/null 2>&1; then
|
|
61
|
+
# Pull session + transcript once — cache ops + model attribution use both.
|
|
62
|
+
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
|
|
63
|
+
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null)
|
|
64
|
+
|
|
65
|
+
# Refresh model cache at once-per-turn waypoints. Cheap tool events
|
|
66
|
+
# (PreToolUse / PostToolUse) read the cache without touching the
|
|
67
|
+
# transcript.
|
|
68
|
+
case "$EVENT" in
|
|
69
|
+
SessionStart|Stop)
|
|
70
|
+
_mp_update_model "$SESSION_ID" "$TRANSCRIPT"
|
|
71
|
+
;;
|
|
72
|
+
SessionEnd)
|
|
73
|
+
_mp_clear_model "$SESSION_ID"
|
|
74
|
+
;;
|
|
75
|
+
esac
|
|
76
|
+
|
|
77
|
+
MP_MODEL=$(_mp_read_model "$SESSION_ID")
|
|
78
|
+
|
|
28
79
|
case "$EVENT" in
|
|
29
80
|
UserPromptSubmit)
|
|
30
81
|
# Delegate to Python for structural analysis (shell can't do regex classification)
|
|
@@ -37,9 +88,10 @@ if command -v jq >/dev/null 2>&1; then
|
|
|
37
88
|
;;
|
|
38
89
|
PreToolUse)
|
|
39
90
|
TYPE="tool_call"
|
|
40
|
-
META=$(echo "$INPUT" | jq -c '{
|
|
91
|
+
META=$(echo "$INPUT" | jq -c --arg model "$MP_MODEL" '{
|
|
41
92
|
tool: (.tool_name // "unknown"),
|
|
42
93
|
tool_use_id: (.tool_use_id // ""),
|
|
94
|
+
model: (if $model == "" then null else $model end),
|
|
43
95
|
tool_input: (.tool_input // {}),
|
|
44
96
|
tool_input_preview: (
|
|
45
97
|
(.tool_input // {}) as $ti |
|
|
@@ -58,10 +110,11 @@ if command -v jq >/dev/null 2>&1; then
|
|
|
58
110
|
;;
|
|
59
111
|
PostToolUse)
|
|
60
112
|
TYPE="tool_result"
|
|
61
|
-
META=$(echo "$INPUT" | jq -c '{
|
|
113
|
+
META=$(echo "$INPUT" | jq -c --arg model "$MP_MODEL" '{
|
|
62
114
|
tool: (.tool_name // "unknown"),
|
|
63
115
|
tool_use_id: (.tool_use_id // ""),
|
|
64
116
|
success: true,
|
|
117
|
+
model: (if $model == "" then null else $model end),
|
|
65
118
|
tool_input: (.tool_input // {}),
|
|
66
119
|
tool_response: (.tool_response // {}),
|
|
67
120
|
tool_input_preview: (
|
|
@@ -112,7 +165,11 @@ if command -v jq >/dev/null 2>&1; then
|
|
|
112
165
|
;;
|
|
113
166
|
SessionStart)
|
|
114
167
|
TYPE="claude_session_start"
|
|
115
|
-
META=$(echo "$INPUT" | jq -c
|
|
168
|
+
META=$(echo "$INPUT" | jq -c --arg model "$MP_MODEL" '{
|
|
169
|
+
claude_session_id: (.session_id // ""),
|
|
170
|
+
cwd: (.cwd // ""),
|
|
171
|
+
model: (if $model == "" then null else $model end)
|
|
172
|
+
}' 2>/dev/null || echo '{}')
|
|
116
173
|
;;
|
|
117
174
|
PostToolUseFailure)
|
|
118
175
|
TYPE="tool_failure"
|
|
@@ -124,7 +181,11 @@ if command -v jq >/dev/null 2>&1; then
|
|
|
124
181
|
;;
|
|
125
182
|
Stop)
|
|
126
183
|
TYPE="agent_turn_end"
|
|
127
|
-
|
|
184
|
+
if [ -n "$MP_MODEL" ]; then
|
|
185
|
+
META="{\"tool\":\"claude_code\",\"model\":\"$MP_MODEL\"}"
|
|
186
|
+
else
|
|
187
|
+
META='{"tool":"claude_code"}'
|
|
188
|
+
fi
|
|
128
189
|
# Extract recap from transcript if available (journal mode)
|
|
129
190
|
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
130
191
|
if [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ]; then
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Per-session model cache for Claude Code capture.
|
|
2
|
+
|
|
3
|
+
Claude Code doesn't pass the active model in every hook payload — only the
|
|
4
|
+
transcript JSONL carries it. Re-reading the transcript on every PreToolUse
|
|
5
|
+
would add tens of ms to each hook invocation. This module keeps a tiny
|
|
6
|
+
JSON cache at ``~/.methodproof/hook_state/models.json`` mapping Claude
|
|
7
|
+
session_id → (model, updated_at), refreshed once per turn at the cheap
|
|
8
|
+
waypoints (``SessionStart``, ``UserPromptSubmit``, ``Stop``) and read
|
|
9
|
+
cheaply on every tool event.
|
|
10
|
+
|
|
11
|
+
Atomic writes via ``tempfile.NamedTemporaryFile`` + ``os.replace`` so
|
|
12
|
+
concurrent hook invocations never corrupt the file.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
CACHE_PATH = pathlib.Path.home() / ".methodproof" / "hook_state" / "models.json"
|
|
25
|
+
# How far back to scan a transcript for the last assistant message's model.
|
|
26
|
+
# Transcripts are JSONL append-only, so tail is all we need. 200 lines covers
|
|
27
|
+
# a typical turn plus headroom; we're not trying to reconstruct history.
|
|
28
|
+
_TAIL_BYTES = 64 * 1024
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load() -> dict:
|
|
32
|
+
try:
|
|
33
|
+
with CACHE_PATH.open("r") as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
return data if isinstance(data, dict) else {}
|
|
36
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _save(data: dict) -> None:
|
|
41
|
+
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
# Atomic write — write to a tmp file in the same dir, rename over.
|
|
43
|
+
with tempfile.NamedTemporaryFile(
|
|
44
|
+
mode="w", dir=str(CACHE_PATH.parent), delete=False, suffix=".json",
|
|
45
|
+
) as tmp:
|
|
46
|
+
json.dump(data, tmp)
|
|
47
|
+
tmp_path = tmp.name
|
|
48
|
+
os.replace(tmp_path, CACHE_PATH)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_last_model(transcript_path: str) -> str | None:
|
|
52
|
+
"""Read the tail of a transcript JSONL and return the most recent
|
|
53
|
+
``model`` field from an assistant message. ``None`` if the transcript
|
|
54
|
+
is missing, unreadable, or contains no model annotation.
|
|
55
|
+
"""
|
|
56
|
+
path = pathlib.Path(transcript_path)
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
size = path.stat().st_size
|
|
61
|
+
with path.open("rb") as f:
|
|
62
|
+
if size > _TAIL_BYTES:
|
|
63
|
+
f.seek(size - _TAIL_BYTES)
|
|
64
|
+
# Drop partial first line after a seek
|
|
65
|
+
f.readline()
|
|
66
|
+
blob = f.read().decode("utf-8", errors="replace")
|
|
67
|
+
except OSError:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
last_model: str | None = None
|
|
71
|
+
for line in blob.splitlines():
|
|
72
|
+
line = line.strip()
|
|
73
|
+
if not line or not line.startswith("{"):
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
rec = json.loads(line)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
continue
|
|
79
|
+
if not isinstance(rec, dict):
|
|
80
|
+
continue
|
|
81
|
+
# Claude Code transcript shape: top-level "model" on assistant messages.
|
|
82
|
+
model = rec.get("model")
|
|
83
|
+
if isinstance(model, str) and model:
|
|
84
|
+
last_model = model
|
|
85
|
+
return last_model
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def update_from_transcript(session_id: str, transcript_path: str) -> str | None:
|
|
89
|
+
"""Read the transcript tail, extract the most recent model, and persist
|
|
90
|
+
it in the cache keyed by ``session_id``. Returns the model string (or
|
|
91
|
+
``None`` if extraction failed — cache untouched in that case).
|
|
92
|
+
"""
|
|
93
|
+
if not session_id or not transcript_path:
|
|
94
|
+
return None
|
|
95
|
+
model = _extract_last_model(transcript_path)
|
|
96
|
+
if model is None:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
data = _load()
|
|
100
|
+
data[session_id] = {"model": model, "updated_at": time.time()}
|
|
101
|
+
_save(data)
|
|
102
|
+
except OSError:
|
|
103
|
+
# Cache is best-effort. A write failure must not break the hook.
|
|
104
|
+
return model
|
|
105
|
+
return model
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_model(session_id: str) -> str | None:
|
|
109
|
+
"""Return the cached model for ``session_id``, or ``None`` if no cache
|
|
110
|
+
entry exists. Never raises — the cache is best-effort."""
|
|
111
|
+
if not session_id:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
data = _load()
|
|
115
|
+
except OSError:
|
|
116
|
+
return None
|
|
117
|
+
entry = data.get(session_id)
|
|
118
|
+
if not isinstance(entry, dict):
|
|
119
|
+
return None
|
|
120
|
+
model = entry.get("model")
|
|
121
|
+
return model if isinstance(model, str) and model else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def clear_session(session_id: str) -> None:
|
|
125
|
+
"""Remove a session's cache entry. Called on ``SessionEnd`` so cache
|
|
126
|
+
size stays bounded over time."""
|
|
127
|
+
if not session_id:
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
data = _load()
|
|
131
|
+
if session_id in data:
|
|
132
|
+
del data[session_id]
|
|
133
|
+
_save(data)
|
|
134
|
+
except OSError:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# CLI entry so the shell hook can do `python3 -m methodproof.hooks.model_cache ...`
|
|
139
|
+
# on rare events (SessionStart / Stop / SessionEnd). The hot read path in shell
|
|
140
|
+
# uses jq directly on the cache file — no Python subprocess needed.
|
|
141
|
+
def _main() -> int:
|
|
142
|
+
import sys
|
|
143
|
+
args = sys.argv[1:]
|
|
144
|
+
if len(args) < 1:
|
|
145
|
+
return 1
|
|
146
|
+
cmd = args[0]
|
|
147
|
+
if cmd == "update" and len(args) == 3:
|
|
148
|
+
model = update_from_transcript(args[1], args[2])
|
|
149
|
+
if model:
|
|
150
|
+
print(model)
|
|
151
|
+
return 0
|
|
152
|
+
if cmd == "get" and len(args) == 2:
|
|
153
|
+
model = get_model(args[1])
|
|
154
|
+
if model:
|
|
155
|
+
print(model)
|
|
156
|
+
return 0
|
|
157
|
+
if cmd == "clear" and len(args) == 2:
|
|
158
|
+
clear_session(args[1])
|
|
159
|
+
return 0
|
|
160
|
+
return 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
raise SystemExit(_main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "methodproof"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.4"
|
|
4
4
|
description = "See how you code. Capture and visualize your engineering process."
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
dependencies = ["watchdog>=4.0", "websocket-client>=1.7", "cryptography>=43.0", "keyring>=25.0", "textual>=0.59", "rich>=13.7", "sqlcipher3>=0.6"]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Per-session model cache tests — drives the Claude Code hook's model
|
|
2
|
+
attribution pipeline. See `methodproof/hooks/model_cache.py`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from methodproof.hooks import model_cache
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def isolated_cache(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
|
|
15
|
+
"""Redirect cache to a tmpdir so tests don't touch a developer's real
|
|
16
|
+
`~/.methodproof/hook_state/models.json`. Uses a dedicated subdir so
|
|
17
|
+
the sibling-files assertion is tight."""
|
|
18
|
+
cache_dir = tmp_path / "hook_state"
|
|
19
|
+
cache_file = cache_dir / "models.json"
|
|
20
|
+
monkeypatch.setattr(model_cache, "CACHE_PATH", cache_file)
|
|
21
|
+
return cache_file
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write_transcript(path: pathlib.Path, records: list[dict]) -> None:
|
|
25
|
+
path.write_text("\n".join(json.dumps(r) for r in records) + "\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── extract_last_model ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_extract_returns_last_model_in_transcript(tmp_path: pathlib.Path) -> None:
|
|
32
|
+
"""When the transcript carries multiple assistant messages with
|
|
33
|
+
different models, `_extract_last_model` returns the final one —
|
|
34
|
+
the model that was active most recently."""
|
|
35
|
+
transcript = tmp_path / "t.jsonl"
|
|
36
|
+
_write_transcript(transcript, [
|
|
37
|
+
{"type": "user", "message": {"content": "hi"}},
|
|
38
|
+
{"type": "assistant", "model": "claude-haiku-4-5", "message": {}},
|
|
39
|
+
{"type": "user", "message": {}},
|
|
40
|
+
{"type": "assistant", "model": "claude-sonnet-4-5", "message": {}},
|
|
41
|
+
])
|
|
42
|
+
assert model_cache._extract_last_model(str(transcript)) == "claude-sonnet-4-5"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_extract_returns_none_when_no_model_field(tmp_path: pathlib.Path) -> None:
|
|
46
|
+
"""Transcripts without an assistant message (or without a model on
|
|
47
|
+
any message) yield None — the hook falls back to 'no model attribution'."""
|
|
48
|
+
transcript = tmp_path / "t.jsonl"
|
|
49
|
+
_write_transcript(transcript, [
|
|
50
|
+
{"type": "user", "message": {"content": "hi"}},
|
|
51
|
+
])
|
|
52
|
+
assert model_cache._extract_last_model(str(transcript)) is None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_extract_skips_malformed_lines(tmp_path: pathlib.Path) -> None:
|
|
56
|
+
"""Corrupted / partial JSON lines must not crash extraction —
|
|
57
|
+
hooks run inline and cannot raise."""
|
|
58
|
+
transcript = tmp_path / "t.jsonl"
|
|
59
|
+
transcript.write_text(
|
|
60
|
+
'not-json\n'
|
|
61
|
+
+ json.dumps({"type": "assistant", "model": "claude-opus-4-7"})
|
|
62
|
+
+ "\n{incomplete\n"
|
|
63
|
+
)
|
|
64
|
+
assert model_cache._extract_last_model(str(transcript)) == "claude-opus-4-7"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_extract_nonexistent_file_returns_none() -> None:
|
|
68
|
+
assert model_cache._extract_last_model("/nonexistent/transcript.jsonl") is None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_extract_uses_tail_only_on_large_transcript(tmp_path: pathlib.Path) -> None:
|
|
72
|
+
"""Long transcripts (>64KB) are tailed, not fully read. We seek past
|
|
73
|
+
the first 64KB from the end and drop the partial first line, so a
|
|
74
|
+
model set in the first KB will NOT appear. This is intentional —
|
|
75
|
+
we want the CURRENT model, not the original."""
|
|
76
|
+
transcript = tmp_path / "t.jsonl"
|
|
77
|
+
# Pad with 80 KB of text; final assistant record at the very end.
|
|
78
|
+
padding = json.dumps({"type": "user", "message": {"content": "x" * 200}}) + "\n"
|
|
79
|
+
early_model = json.dumps({"type": "assistant", "model": "claude-haiku-4-5"}) + "\n"
|
|
80
|
+
late_model = json.dumps({"type": "assistant", "model": "claude-opus-4-7"}) + "\n"
|
|
81
|
+
transcript.write_text(early_model + padding * 400 + late_model)
|
|
82
|
+
assert model_cache._extract_last_model(str(transcript)) == "claude-opus-4-7"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── update_from_transcript ─────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_update_persists_and_returns_model(
|
|
89
|
+
tmp_path: pathlib.Path, isolated_cache: pathlib.Path,
|
|
90
|
+
) -> None:
|
|
91
|
+
transcript = tmp_path / "t.jsonl"
|
|
92
|
+
_write_transcript(transcript, [
|
|
93
|
+
{"type": "assistant", "model": "claude-sonnet-4-5"},
|
|
94
|
+
])
|
|
95
|
+
result = model_cache.update_from_transcript("sess-1", str(transcript))
|
|
96
|
+
assert result == "claude-sonnet-4-5"
|
|
97
|
+
|
|
98
|
+
data = json.loads(isolated_cache.read_text())
|
|
99
|
+
assert data["sess-1"]["model"] == "claude-sonnet-4-5"
|
|
100
|
+
assert isinstance(data["sess-1"]["updated_at"], (int, float))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_update_preserves_other_sessions(
|
|
104
|
+
tmp_path: pathlib.Path, isolated_cache: pathlib.Path,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Multiple concurrent Claude Code sessions must coexist in the cache
|
|
107
|
+
without clobbering each other — e.g., two worktrees each running
|
|
108
|
+
`claude` simultaneously."""
|
|
109
|
+
t1 = tmp_path / "t1.jsonl"
|
|
110
|
+
t2 = tmp_path / "t2.jsonl"
|
|
111
|
+
_write_transcript(t1, [{"type": "assistant", "model": "claude-haiku-4-5"}])
|
|
112
|
+
_write_transcript(t2, [{"type": "assistant", "model": "claude-opus-4-7"}])
|
|
113
|
+
|
|
114
|
+
model_cache.update_from_transcript("sess-A", str(t1))
|
|
115
|
+
model_cache.update_from_transcript("sess-B", str(t2))
|
|
116
|
+
|
|
117
|
+
assert model_cache.get_model("sess-A") == "claude-haiku-4-5"
|
|
118
|
+
assert model_cache.get_model("sess-B") == "claude-opus-4-7"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_update_with_no_model_in_transcript_leaves_cache_untouched(
|
|
122
|
+
tmp_path: pathlib.Path, isolated_cache: pathlib.Path,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Transcripts with no model yield None — the existing cache entry
|
|
125
|
+
(from a prior update) must not be wiped. Otherwise a mid-session
|
|
126
|
+
refresh against an incomplete transcript would erase attribution."""
|
|
127
|
+
t1 = tmp_path / "t1.jsonl"
|
|
128
|
+
_write_transcript(t1, [{"type": "assistant", "model": "claude-sonnet-4-5"}])
|
|
129
|
+
model_cache.update_from_transcript("sess-X", str(t1))
|
|
130
|
+
|
|
131
|
+
t2 = tmp_path / "t2.jsonl"
|
|
132
|
+
_write_transcript(t2, [{"type": "user", "message": {"content": "hi"}}])
|
|
133
|
+
result = model_cache.update_from_transcript("sess-X", str(t2))
|
|
134
|
+
assert result is None
|
|
135
|
+
# Cache preserved.
|
|
136
|
+
assert model_cache.get_model("sess-X") == "claude-sonnet-4-5"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_update_with_missing_transcript_path_noops(isolated_cache: pathlib.Path) -> None:
|
|
140
|
+
assert model_cache.update_from_transcript("sess-1", "") is None
|
|
141
|
+
assert model_cache.update_from_transcript("", "/some/path.jsonl") is None
|
|
142
|
+
assert not isolated_cache.exists()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── get_model / clear_session ──────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_get_model_returns_none_when_no_cache() -> None:
|
|
149
|
+
assert model_cache.get_model("never-seen") is None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_clear_session_removes_entry(
|
|
153
|
+
tmp_path: pathlib.Path, isolated_cache: pathlib.Path,
|
|
154
|
+
) -> None:
|
|
155
|
+
t = tmp_path / "t.jsonl"
|
|
156
|
+
_write_transcript(t, [{"type": "assistant", "model": "claude-sonnet-4-5"}])
|
|
157
|
+
model_cache.update_from_transcript("sess-cleanup", str(t))
|
|
158
|
+
assert model_cache.get_model("sess-cleanup") == "claude-sonnet-4-5"
|
|
159
|
+
|
|
160
|
+
model_cache.clear_session("sess-cleanup")
|
|
161
|
+
assert model_cache.get_model("sess-cleanup") is None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_corrupted_cache_file_does_not_raise(isolated_cache: pathlib.Path) -> None:
|
|
165
|
+
"""A user who hand-edits (or a crash that truncates) the cache file
|
|
166
|
+
must not break the hook — we silently treat corrupt cache as empty."""
|
|
167
|
+
isolated_cache.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
isolated_cache.write_text("not valid JSON {{{")
|
|
169
|
+
assert model_cache.get_model("anything") is None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_atomic_write_does_not_leave_stale_tmp_files(
|
|
173
|
+
tmp_path: pathlib.Path, isolated_cache: pathlib.Path,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""The save path uses NamedTemporaryFile + os.replace. After a
|
|
176
|
+
successful update, the cache dir should contain only models.json —
|
|
177
|
+
no leftover .tmp* files."""
|
|
178
|
+
t = tmp_path / "t.jsonl"
|
|
179
|
+
_write_transcript(t, [{"type": "assistant", "model": "claude-sonnet-4-5"}])
|
|
180
|
+
model_cache.update_from_transcript("sess-1", str(t))
|
|
181
|
+
siblings = list(isolated_cache.parent.iterdir())
|
|
182
|
+
assert siblings == [isolated_cache], f"stale tmp files: {siblings}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── Hook integration: model flows into emitted event metadata ─────────────
|
|
186
|
+
|
|
187
|
+
def test_claude_code_hook_pretooluse_attaches_cached_model(
|
|
188
|
+
tmp_path: pathlib.Path, isolated_cache: pathlib.Path,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""End-to-end: a PreToolUse payload emitted by the Python hook
|
|
191
|
+
carries the session's currently-cached model. This is the whole
|
|
192
|
+
point of the cache — tool events need model attribution without
|
|
193
|
+
re-reading the transcript on every fire."""
|
|
194
|
+
from methodproof.hooks import claude_code as hook
|
|
195
|
+
|
|
196
|
+
# Prime the cache with this session's model.
|
|
197
|
+
transcript = tmp_path / "t.jsonl"
|
|
198
|
+
_write_transcript(transcript, [
|
|
199
|
+
{"type": "assistant", "model": "claude-sonnet-4-5"},
|
|
200
|
+
])
|
|
201
|
+
model_cache.update_from_transcript("sess-test", str(transcript))
|
|
202
|
+
|
|
203
|
+
# Simulate the PreToolUse stdin payload Claude Code would send.
|
|
204
|
+
payload = {
|
|
205
|
+
"hook_event_name": "PreToolUse",
|
|
206
|
+
"session_id": "sess-test",
|
|
207
|
+
"tool_name": "Edit",
|
|
208
|
+
"tool_use_id": "toolu_test",
|
|
209
|
+
"tool_input": {"file_path": "/abs/path/app.py",
|
|
210
|
+
"old_string": "x", "new_string": "y"},
|
|
211
|
+
}
|
|
212
|
+
meta = hook._META_EXTRACTORS["PreToolUse"](payload)
|
|
213
|
+
assert meta["model"] == "claude-sonnet-4-5"
|
|
214
|
+
assert meta["tool_name"] == "Edit"
|
|
215
|
+
assert meta["tool_input"]["file_path"] == "/abs/path/app.py"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_claude_code_hook_omits_model_when_cache_empty(tmp_path: pathlib.Path) -> None:
|
|
219
|
+
"""No cache entry → no ``model`` key in metadata (not a ``None``
|
|
220
|
+
placeholder). Downstream consumers use `metadata.get("model")` and
|
|
221
|
+
a missing key is the honest answer when we don't know."""
|
|
222
|
+
from methodproof.hooks import claude_code as hook
|
|
223
|
+
|
|
224
|
+
payload = {
|
|
225
|
+
"hook_event_name": "PreToolUse",
|
|
226
|
+
"session_id": "never-cached",
|
|
227
|
+
"tool_name": "Edit",
|
|
228
|
+
"tool_use_id": "toolu_2",
|
|
229
|
+
"tool_input": {"file_path": "/abs/foo.py"},
|
|
230
|
+
}
|
|
231
|
+
meta = hook._META_EXTRACTORS["PreToolUse"](payload)
|
|
232
|
+
assert "model" not in meta
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_main_refreshes_cache_on_pretooluse_first_turn(
|
|
236
|
+
tmp_path: pathlib.Path, isolated_cache: pathlib.Path,
|
|
237
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""First-turn PreToolUse arrives with a cold cache (UserPromptSubmit
|
|
240
|
+
ran before any assistant message hit the transcript). `main()` must
|
|
241
|
+
refresh the cache on PreToolUse so the emitted event carries `model`.
|
|
242
|
+
Without this, every tool event in the session's first turn lands
|
|
243
|
+
with no model attribution and downstream `model_switch` moments and
|
|
244
|
+
SENT_TO/CONSUMED edges silently fail."""
|
|
245
|
+
import io
|
|
246
|
+
from methodproof.hooks import claude_code as hook
|
|
247
|
+
|
|
248
|
+
transcript = tmp_path / "t.jsonl"
|
|
249
|
+
_write_transcript(transcript, [
|
|
250
|
+
{"type": "assistant", "model": "claude-sonnet-4-5"},
|
|
251
|
+
])
|
|
252
|
+
assert model_cache.get_model("sess-first-turn") is None # cold
|
|
253
|
+
|
|
254
|
+
payload = {
|
|
255
|
+
"hook_event_name": "PreToolUse",
|
|
256
|
+
"session_id": "sess-first-turn",
|
|
257
|
+
"transcript_path": str(transcript),
|
|
258
|
+
"tool_name": "Edit",
|
|
259
|
+
"tool_input": {"file_path": "/abs/app.py"},
|
|
260
|
+
}
|
|
261
|
+
captured: dict = {}
|
|
262
|
+
|
|
263
|
+
def fake_urlopen(req, timeout):
|
|
264
|
+
captured["body"] = json.loads(req.data.decode())
|
|
265
|
+
|
|
266
|
+
class _R:
|
|
267
|
+
def __enter__(self): return self
|
|
268
|
+
def __exit__(self, *a): pass
|
|
269
|
+
return _R()
|
|
270
|
+
|
|
271
|
+
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload)))
|
|
272
|
+
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
|
273
|
+
hook.main()
|
|
274
|
+
|
|
275
|
+
assert model_cache.get_model("sess-first-turn") == "claude-sonnet-4-5"
|
|
276
|
+
events = captured["body"]["events"]
|
|
277
|
+
assert events[0]["type"] == "tool_call"
|
|
278
|
+
assert events[0]["metadata"]["model"] == "claude-sonnet-4-5"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""IGNORE_PATTERNS — watcher exclusions for runtime log output.
|
|
2
|
+
|
|
3
|
+
Regression guards for the 8c21 prod symptom: the platform's own log
|
|
4
|
+
file ``methodproof-platform/logs/methodproof-platform.jsonl`` captured
|
|
5
|
+
as file_edit events, polluting both thread and step distributions with
|
|
6
|
+
15,269 spurious events.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from methodproof.agents.watcher import IGNORE_PATTERNS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ignored(path: str) -> bool:
|
|
13
|
+
return bool(IGNORE_PATTERNS.search(path))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── log-output directories excluded ──────────────────────────────────
|
|
17
|
+
|
|
18
|
+
def test_jsonl_log_in_logs_dir_excluded() -> None:
|
|
19
|
+
"""The 8c21 pathology: jsonl logs under ``logs/`` must not capture."""
|
|
20
|
+
assert _ignored("/repo/methodproof-platform/logs/methodproof-platform.jsonl")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_log_file_in_logs_dir_excluded() -> None:
|
|
24
|
+
assert _ignored("/repo/project/logs/app.log")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_arbitrary_extension_in_logs_dir_excluded() -> None:
|
|
28
|
+
"""Any file under ``logs/`` — txt, out, ndjson, etc. — is runtime output."""
|
|
29
|
+
assert _ignored("/repo/project/logs/events.ndjson")
|
|
30
|
+
assert _ignored("/repo/project/logs/stdout.txt")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_nested_logs_dir_excluded() -> None:
|
|
34
|
+
"""Deeper logs dirs still match (watchdog sees absolute paths)."""
|
|
35
|
+
assert _ignored("/repo/pkg/sub/logs/trace.jsonl")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── legit source files with similar names NOT excluded ─────────────
|
|
39
|
+
|
|
40
|
+
def test_source_file_named_logs_not_excluded() -> None:
|
|
41
|
+
"""A source file named ``logs.py`` is not under ``/logs/`` and stays captured."""
|
|
42
|
+
assert not _ignored("/repo/project/app/logs.py")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_logger_module_file_not_excluded() -> None:
|
|
46
|
+
"""``app/logging/formatter.py`` has ``logging`` in path but no ``/logs/``."""
|
|
47
|
+
assert not _ignored("/repo/project/app/logging/formatter.py")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── existing exclusions still work ──────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def test_log_extension_still_excluded() -> None:
|
|
53
|
+
"""The original ``\\.log$`` check still fires on top-level .log files."""
|
|
54
|
+
assert _ignored("/repo/project/app.log")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_lock_extension_still_excluded() -> None:
|
|
58
|
+
assert _ignored("/repo/project/package-lock.lock")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_node_modules_still_excluded() -> None:
|
|
62
|
+
"""Sanity — don't break the other exclusions."""
|
|
63
|
+
assert _ignored("/repo/project/node_modules/react/index.js")
|
|
@@ -1016,7 +1016,7 @@ wheels = [
|
|
|
1016
1016
|
|
|
1017
1017
|
[[package]]
|
|
1018
1018
|
name = "methodproof"
|
|
1019
|
-
version = "0.8.
|
|
1019
|
+
version = "0.8.4"
|
|
1020
1020
|
source = { editable = "." }
|
|
1021
1021
|
dependencies = [
|
|
1022
1022
|
{ name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|