methodproof 0.8.2__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.
Files changed (90) hide show
  1. {methodproof-0.8.2 → methodproof-0.8.4}/PKG-INFO +1 -1
  2. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/__init__.py +1 -1
  3. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/agents/watcher.py +9 -2
  4. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/claude_code.py +89 -9
  5. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/claude_code.sh +75 -5
  6. methodproof-0.8.4/methodproof/hooks/model_cache.py +164 -0
  7. {methodproof-0.8.2 → methodproof-0.8.4}/pyproject.toml +1 -1
  8. methodproof-0.8.4/tests/test_model_cache.py +278 -0
  9. methodproof-0.8.4/tests/test_watcher_ignore.py +63 -0
  10. {methodproof-0.8.2 → methodproof-0.8.4}/uv.lock +1 -1
  11. {methodproof-0.8.2 → methodproof-0.8.4}/.github/workflows/ci.yml +0 -0
  12. {methodproof-0.8.2 → methodproof-0.8.4}/.gitignore +0 -0
  13. {methodproof-0.8.2 → methodproof-0.8.4}/CHANGELOG.md +0 -0
  14. {methodproof-0.8.2 → methodproof-0.8.4}/LICENSE +0 -0
  15. {methodproof-0.8.2 → methodproof-0.8.4}/README.md +0 -0
  16. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/__main__.py +0 -0
  17. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/_daemon.py +0 -0
  18. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/agents/__init__.py +0 -0
  19. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/agents/base.py +0 -0
  20. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/agents/music.py +0 -0
  21. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/agents/terminal.py +0 -0
  22. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/analysis.py +0 -0
  23. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/binding.py +0 -0
  24. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/bip39.py +0 -0
  25. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/bridge.py +0 -0
  26. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/cli.py +0 -0
  27. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/config.py +0 -0
  28. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/crypto.py +0 -0
  29. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/e2e.py +0 -0
  30. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/graph.py +0 -0
  31. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hook.py +0 -0
  32. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/__init__.py +0 -0
  33. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/cline_hook.sh +0 -0
  34. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/codex_hook.sh +0 -0
  35. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/gemini_hook.sh +0 -0
  36. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/install.py +0 -0
  37. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/kiro_hook.sh +0 -0
  38. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/mcp_register.py +0 -0
  39. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/openclaw/HOOK.md +0 -0
  40. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/openclaw/handler.ts +0 -0
  41. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/openclaw_install.py +0 -0
  42. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/opencode_plugin.js +0 -0
  43. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/hooks/wrappers.py +0 -0
  44. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/integrity.py +0 -0
  45. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/kdf.py +0 -0
  46. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/keychain.py +0 -0
  47. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/live.py +0 -0
  48. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/lock.py +0 -0
  49. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/mcp.py +0 -0
  50. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/migrate_db.py +0 -0
  51. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/proxy.py +0 -0
  52. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/proxy_daemon.py +0 -0
  53. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/repos.py +0 -0
  54. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/skills/methodproof/SKILL.md +0 -0
  55. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/store.py +0 -0
  56. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/sync.py +0 -0
  57. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/__init__.py +0 -0
  58. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/consent.py +0 -0
  59. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/init.py +0 -0
  60. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/log.py +0 -0
  61. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/login_success.py +0 -0
  62. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/review.py +0 -0
  63. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/start.py +0 -0
  64. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/status.py +0 -0
  65. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/tui/theme.py +0 -0
  66. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/viewer.py +0 -0
  67. {methodproof-0.8.2 → methodproof-0.8.4}/methodproof/wordlist.py +0 -0
  68. {methodproof-0.8.2 → methodproof-0.8.4}/test_windows_compat.py +0 -0
  69. {methodproof-0.8.2 → methodproof-0.8.4}/tests/__init__.py +0 -0
  70. {methodproof-0.8.2 → methodproof-0.8.4}/tests/conftest.py +0 -0
  71. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_analysis.py +0 -0
  72. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_cli_auth.py +0 -0
  73. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_cli_config.py +0 -0
  74. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_cli_helpers.py +0 -0
  75. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_cli_session.py +0 -0
  76. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_cli_share.py +0 -0
  77. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_cli_start.py +0 -0
  78. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_cli_update.py +0 -0
  79. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_e2e_integration.py +0 -0
  80. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_graph.py +0 -0
  81. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_hooks.py +0 -0
  82. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_live.py +0 -0
  83. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_openclaw_hooks.py +0 -0
  84. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_profiles.py +0 -0
  85. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_repos.py +0 -0
  86. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_security.py +0 -0
  87. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_store.py +0 -0
  88. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_sync.py +0 -0
  89. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_viewer.py +0 -0
  90. {methodproof-0.8.2 → methodproof-0.8.4}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.8.2
3
+ Version: 0.8.4
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
@@ -1,3 +1,3 @@
1
1
  """MethodProof — see how you code."""
2
2
 
3
- __version__ = "0.8.1"
3
+ __version__ = "0.8.2"
@@ -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
- r"|\.lock$|\.log$)"
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", ""),
@@ -150,6 +175,24 @@ _META_EXTRACTORS = {
150
175
  }
151
176
 
152
177
 
178
+ def _extract_recap(transcript_path: str) -> str | None:
179
+ """Grep transcript for the last ※ recap: line. Journal mode only."""
180
+ import pathlib
181
+ tp = pathlib.Path(transcript_path)
182
+ if not tp.exists():
183
+ return None
184
+ try:
185
+ text = tp.read_text(errors="replace")
186
+ except OSError:
187
+ return None
188
+ last_recap = None
189
+ for line in text.splitlines():
190
+ stripped = line.strip()
191
+ if stripped.startswith("※ recap:") or stripped.startswith("recap:"):
192
+ last_recap = stripped.removeprefix("※ ").removeprefix("recap:").strip()
193
+ return last_recap
194
+
195
+
153
196
  def main() -> None:
154
197
  try:
155
198
  data = json.load(sys.stdin)
@@ -157,12 +200,49 @@ def main() -> None:
157
200
  return
158
201
 
159
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
+
160
221
  etype = _TYPE_MAP.get(event, "claude_code_event")
161
222
  extractor = _META_EXTRACTORS.get(event)
162
223
  meta = extractor(data) if extractor else {"tool": _TOOL, "event": event}
163
224
  ts = time.time()
164
225
 
165
- payload = json.dumps({"events": [{"type": etype, "timestamp": ts, "metadata": meta}]}).encode()
226
+ events_out = [{"type": etype, "timestamp": ts, "metadata": meta}]
227
+
228
+ # On Stop, grep transcript for recap (journal mode only)
229
+ if event == "Stop" and transcript_path:
230
+ recap = _extract_recap(transcript_path)
231
+ if recap:
232
+ events_out.append({
233
+ "type": "context_recap",
234
+ "timestamp": ts,
235
+ "metadata": {"tool": _TOOL, "recap": recap[:2000]},
236
+ })
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
+
245
+ payload = json.dumps({"events": events_out}).encode()
166
246
  req = urllib.request.Request(
167
247
  "http://localhost:9877/events", data=payload,
168
248
  headers={"Content-Type": "application/json"}, method="POST",
@@ -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 '{claude_session_id: (.session_id // ""), cwd: (.cwd // "")}' 2>/dev/null || echo '{}')
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,20 @@ if command -v jq >/dev/null 2>&1; then
124
181
  ;;
125
182
  Stop)
126
183
  TYPE="agent_turn_end"
127
- META='{"tool":"claude_code"}'
184
+ if [ -n "$MP_MODEL" ]; then
185
+ META="{\"tool\":\"claude_code\",\"model\":\"$MP_MODEL\"}"
186
+ else
187
+ META='{"tool":"claude_code"}'
188
+ fi
189
+ # Extract recap from transcript if available (journal mode)
190
+ TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
191
+ if [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ]; then
192
+ RECAP=$(grep -E '※ recap:|^recap:' "$TRANSCRIPT" | tail -1 | sed 's/^.*※ recap: *//;s/^recap: *//' | head -c 2000)
193
+ if [ -n "$RECAP" ]; then
194
+ RECAP_ESCAPED=$(echo "$RECAP" | jq -Rs '.' 2>/dev/null || echo '""')
195
+ RECAP_EVENT=",{\"type\":\"context_recap\",\"timestamp\":$TS,\"metadata\":{\"tool\":\"claude_code\",\"recap\":$RECAP_ESCAPED}}"
196
+ fi
197
+ fi
128
198
  ;;
129
199
  StopFailure)
130
200
  TYPE="agent_turn_error"
@@ -178,7 +248,7 @@ else
178
248
  fi
179
249
 
180
250
  # Post to bridge with strict 1-second timeout (never block Claude Code)
181
- PAYLOAD="{\"events\":[{\"type\":\"$TYPE\",\"timestamp\":$TS,\"metadata\":$META}]}"
251
+ PAYLOAD="{\"events\":[{\"type\":\"$TYPE\",\"timestamp\":$TS,\"metadata\":$META}${RECAP_EVENT:-}]}"
182
252
  RESPONSE=$(curl -s -w "\n%{http_code}" --max-time 1 --connect-timeout 0.5 \
183
253
  -X POST http://localhost:9877/events \
184
254
  -H "Content-Type: application/json" \
@@ -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.2"
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.0"
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