methodproof 0.8.3__tar.gz → 0.8.5__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.5}/PKG-INFO +4 -4
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/__init__.py +1 -1
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/watcher.py +9 -2
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/claude_code.py +58 -9
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/claude_code.sh +65 -4
- methodproof-0.8.5/methodproof/hooks/model_cache.py +164 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/pyproject.toml +5 -5
- methodproof-0.8.5/tests/test_model_cache.py +278 -0
- methodproof-0.8.5/tests/test_watcher_ignore.py +63 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/uv.lock +155 -954
- {methodproof-0.8.3 → methodproof-0.8.5}/.github/workflows/ci.yml +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/.gitignore +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/CHANGELOG.md +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/LICENSE +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/README.md +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/__main__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/_daemon.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/base.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/music.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/terminal.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/analysis.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/binding.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/bip39.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/bridge.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/cli.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/config.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/crypto.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/e2e.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/graph.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hook.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/install.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/integrity.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/kdf.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/keychain.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/live.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/lock.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/mcp.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/migrate_db.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/proxy.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/repos.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/store.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/sync.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/__init__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/consent.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/init.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/log.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/login_success.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/review.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/start.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/status.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/theme.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/viewer.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/wordlist.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/test_windows_compat.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/__init__.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/conftest.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_analysis.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_auth.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_config.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_helpers.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_session.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_share.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_start.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_update.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_e2e_integration.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_graph.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_hooks.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_live.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_profiles.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_repos.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_security.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_store.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_sync.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_viewer.py +0 -0
- {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_wrappers.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: methodproof
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.5
|
|
4
4
|
Summary: See how you code. Capture and visualize your engineering process.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
7
|
-
Requires-Python: >=3.
|
|
8
|
-
Requires-Dist: cryptography>=
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: cryptography>=46.0.7
|
|
9
9
|
Requires-Dist: keyring>=25.0
|
|
10
10
|
Requires-Dist: rich>=13.7
|
|
11
11
|
Requires-Dist: sqlcipher3>=0.6
|
|
@@ -13,7 +13,7 @@ Requires-Dist: textual>=0.59
|
|
|
13
13
|
Requires-Dist: watchdog>=4.0
|
|
14
14
|
Requires-Dist: websocket-client>=1.7
|
|
15
15
|
Provides-Extra: proxy
|
|
16
|
-
Requires-Dist: mitmproxy>=
|
|
16
|
+
Requires-Dist: mitmproxy>=12.2.2; extra == 'proxy'
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
19
19
|
<p align="center">
|
|
@@ -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,17 +1,17 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "methodproof"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.5"
|
|
4
4
|
description = "See how you code. Capture and visualize your engineering process."
|
|
5
|
-
requires-python = ">=3.
|
|
6
|
-
dependencies = ["watchdog>=4.0", "websocket-client>=1.7", "cryptography>=
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = ["watchdog>=4.0", "websocket-client>=1.7", "cryptography>=46.0.7", "keyring>=25.0", "textual>=0.59", "rich>=13.7", "sqlcipher3>=0.6"]
|
|
7
7
|
license = "Apache-2.0"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
|
-
proxy = ["mitmproxy>=
|
|
11
|
+
proxy = ["mitmproxy>=12.2.2"]
|
|
12
12
|
|
|
13
13
|
[dependency-groups]
|
|
14
|
-
dev = ["pytest>=
|
|
14
|
+
dev = ["pytest>=9.0.3", "pytest-cov>=5.0"]
|
|
15
15
|
|
|
16
16
|
[project.scripts]
|
|
17
17
|
methodproof = "methodproof.cli:main"
|