methodproof 0.8.0__tar.gz → 0.8.2__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 (88) hide show
  1. {methodproof-0.8.0 → methodproof-0.8.2}/CHANGELOG.md +19 -0
  2. {methodproof-0.8.0 → methodproof-0.8.2}/PKG-INFO +1 -1
  3. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/__init__.py +1 -1
  4. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/base.py +29 -0
  5. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/terminal.py +45 -5
  6. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/watcher.py +62 -19
  7. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/cli.py +0 -2
  8. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/graph.py +88 -41
  9. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/claude_code.py +10 -3
  10. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/claude_code.sh +4 -0
  11. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/install.py +1 -0
  12. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/log.py +106 -12
  13. methodproof-0.8.2/methodproof/tui/theme.py +156 -0
  14. {methodproof-0.8.0 → methodproof-0.8.2}/pyproject.toml +1 -1
  15. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_graph.py +5 -4
  16. methodproof-0.8.0/methodproof/tui/theme.py +0 -106
  17. {methodproof-0.8.0 → methodproof-0.8.2}/.github/workflows/ci.yml +0 -0
  18. {methodproof-0.8.0 → methodproof-0.8.2}/.gitignore +0 -0
  19. {methodproof-0.8.0 → methodproof-0.8.2}/LICENSE +0 -0
  20. {methodproof-0.8.0 → methodproof-0.8.2}/README.md +0 -0
  21. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/__main__.py +0 -0
  22. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/_daemon.py +0 -0
  23. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/__init__.py +0 -0
  24. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/music.py +0 -0
  25. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/analysis.py +0 -0
  26. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/binding.py +0 -0
  27. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/bip39.py +0 -0
  28. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/bridge.py +0 -0
  29. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/config.py +0 -0
  30. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/crypto.py +0 -0
  31. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/e2e.py +0 -0
  32. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hook.py +0 -0
  33. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/__init__.py +0 -0
  34. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/cline_hook.sh +0 -0
  35. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/codex_hook.sh +0 -0
  36. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/gemini_hook.sh +0 -0
  37. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/kiro_hook.sh +0 -0
  38. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/mcp_register.py +0 -0
  39. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/openclaw/HOOK.md +0 -0
  40. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/openclaw/handler.ts +0 -0
  41. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/openclaw_install.py +0 -0
  42. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/opencode_plugin.js +0 -0
  43. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/wrappers.py +0 -0
  44. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/integrity.py +0 -0
  45. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/kdf.py +0 -0
  46. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/keychain.py +0 -0
  47. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/live.py +0 -0
  48. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/lock.py +0 -0
  49. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/mcp.py +0 -0
  50. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/migrate_db.py +0 -0
  51. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/proxy.py +0 -0
  52. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/proxy_daemon.py +0 -0
  53. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/repos.py +0 -0
  54. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/skills/methodproof/SKILL.md +0 -0
  55. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/store.py +0 -0
  56. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/sync.py +0 -0
  57. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/__init__.py +0 -0
  58. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/consent.py +0 -0
  59. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/init.py +0 -0
  60. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/login_success.py +0 -0
  61. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/review.py +0 -0
  62. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/start.py +0 -0
  63. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/status.py +0 -0
  64. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/viewer.py +0 -0
  65. {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/wordlist.py +0 -0
  66. {methodproof-0.8.0 → methodproof-0.8.2}/test_windows_compat.py +0 -0
  67. {methodproof-0.8.0 → methodproof-0.8.2}/tests/__init__.py +0 -0
  68. {methodproof-0.8.0 → methodproof-0.8.2}/tests/conftest.py +0 -0
  69. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_analysis.py +0 -0
  70. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_auth.py +0 -0
  71. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_config.py +0 -0
  72. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_helpers.py +0 -0
  73. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_session.py +0 -0
  74. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_share.py +0 -0
  75. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_start.py +0 -0
  76. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_update.py +0 -0
  77. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_e2e_integration.py +0 -0
  78. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_hooks.py +0 -0
  79. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_live.py +0 -0
  80. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_openclaw_hooks.py +0 -0
  81. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_profiles.py +0 -0
  82. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_repos.py +0 -0
  83. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_security.py +0 -0
  84. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_store.py +0 -0
  85. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_sync.py +0 -0
  86. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_viewer.py +0 -0
  87. {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_wrappers.py +0 -0
  88. {methodproof-0.8.0 → methodproof-0.8.2}/uv.lock +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.1] — 2026-04-17
4
+
5
+ ### Added
6
+ - **File rename detection** — watcher `on_moved` handler emits `file_rename` atoms with `old_path`/`new_path` instead of producing two incorrect atoms (delete + create).
7
+ - **`git_branch_switch` event** — git poller reads `.git/HEAD` on each 2s cycle and emits `git_branch_switch` with `old_branch`/`new_branch` when the active branch changes.
8
+ - **`git_commit` branch name** — commits now carry a `branch` field read from `.git/HEAD`.
9
+ - **`git_commit` per-file change status** — `files_changed` is now accompanied by `file_statuses` (`{path: "A"|"M"|"D"|"R"}`) via `--name-status` instead of `--name-only`.
10
+ - **11 new test frameworks** — detection and result parsing for vitest, mocha, rspec, minitest, phpunit, unittest, dotnet test, swift test, gradle test, maven test, and ExUnit (total: 15).
11
+ - **`test_run` carries `cwd`** — forwarded from the shell hook JSONL instead of being discarded.
12
+ - **`is_journal_mode()` public API** — exposed on `agents.base` for agents making structural-vs-full content capture decisions.
13
+
14
+ ### Fixed
15
+ - **15 hook event types bypassed consent gates** — `claude_session_end`, `agent_turn_end`, `agent_turn_error`, `context_compact_start/end`, `permission_request/denied`, `mcp_elicitation/result`, `worktree_create/remove`, `cwd_changed`, `tool_failure`, plus new `file_rename` and `git_branch_switch` — all now properly gated in `_EVENT_GATES`.
16
+ - **Removed all arbitrary data truncation** — no more 50KB diff cap, 2000-line hunk cap, 2000-char commit body cap, 500-char terminal output cap, or 200-char error cap. The CLI captures faithfully; downstream consumers decide what to do with large payloads. Preview fields (derived summaries alongside raw data) are unchanged.
17
+
18
+ ### Changed
19
+ - **TUI inline detail screen** — `mp log` enter key opens a full-screen `DetailScreen` with event mix breakdown and per-type timeline instead of exiting to an external viewer.
20
+ - **SHOMEN/KINMYAKU theme toggle** — `MP_THEME=shomen` selects the light theme; default remains `kinmyaku` (dark). `Theme` dataclass adds `accent`, `cursor_bg`, `cursor_fg` semantic roles. DataTable cursor CSS for both themes.
21
+
3
22
  ## [0.8.0] — 2026-04-15
4
23
 
5
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.8.0
3
+ Version: 0.8.2
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.7.21"
3
+ __version__ = "0.8.1"
@@ -68,6 +68,24 @@ _EVENT_GATES: dict[str, str] = {
68
68
  "gemini_session_end": "ai_responses",
69
69
  "kiro_session_start": "ai_prompts",
70
70
  "kiro_session_end": "ai_responses",
71
+ # Hook lifecycle events (continued)
72
+ "claude_session_end": "ai_responses",
73
+ "agent_turn_end": "ai_responses",
74
+ "agent_turn_error": "ai_responses",
75
+ "context_compact_start": "ai_responses",
76
+ "context_compact_end": "ai_responses",
77
+ "permission_request": "ai_responses",
78
+ "permission_denied": "ai_responses",
79
+ "mcp_elicitation": "ai_responses",
80
+ "mcp_elicitation_result": "ai_responses",
81
+ "worktree_create": "ai_responses",
82
+ "worktree_remove": "ai_responses",
83
+ "cwd_changed": "ai_responses",
84
+ "tool_failure": "ai_responses",
85
+ # File system events
86
+ "file_rename": "file_changes",
87
+ # Git events
88
+ "git_branch_switch": "git_commits",
71
89
  }
72
90
 
73
91
  # Maps capture categories to (event_type, field) pairs for field-level gating.
@@ -142,6 +160,13 @@ def is_content_captured() -> bool:
142
160
  return bool(_capture.get("code_capture", False))
143
161
 
144
162
 
163
+ def is_journal_mode() -> bool:
164
+ """True when journal_mode is enabled — full content capture (prompts,
165
+ completions, diffs, terminal output). Agents use this to decide whether
166
+ to include content fields that are stripped in structural-only mode."""
167
+ return _journal_mode
168
+
169
+
145
170
  def emit(event_type: str, metadata: dict[str, Any]) -> None:
146
171
  if not _initialized:
147
172
  log("warning", "emit.before_init", type=event_type)
@@ -226,6 +251,10 @@ def _stream_event(entry: dict[str, Any]) -> None:
226
251
  detail = f'{meta.get("path", "?")} ({meta.get("size", 0)}B)'
227
252
  elif etype == "file_delete":
228
253
  detail = meta.get("path", "?")
254
+ elif etype == "file_rename":
255
+ detail = f'{meta.get("old_path", "?")} → {meta.get("new_path", "?")}'
256
+ elif etype == "git_branch_switch":
257
+ detail = f'{meta.get("old_branch", "?")} → {meta.get("new_branch", "?")}'
229
258
  elif etype == "terminal_cmd":
230
259
  detail = f'{meta.get("command", "?")[:60]} → exit {meta.get("exit_code", "?")}'
231
260
  elif etype == "test_run":
@@ -20,11 +20,27 @@ TEST_FRAMEWORKS = {
20
20
  "jest": re.compile(r"\bjest\b|npx jest|npm test"),
21
21
  "go_test": re.compile(r"\bgo test\b"),
22
22
  "cargo_test": re.compile(r"\bcargo test\b"),
23
+ "vitest": re.compile(r"\bvitest\b|npx vitest"),
24
+ "mocha": re.compile(r"\bmocha\b|npx mocha"),
25
+ "rspec": re.compile(r"\brspec\b|bundle exec rspec"),
26
+ "minitest": re.compile(r"\bruby\b.*test|rake test"),
27
+ "phpunit": re.compile(r"\bphpunit\b|vendor/bin/phpunit"),
28
+ "unittest": re.compile(r"python.*-m\s+unittest|python.*-m\s+nose"),
29
+ "dotnet_test": re.compile(r"\bdotnet test\b"),
30
+ "swift_test": re.compile(r"\bswift test\b"),
31
+ "gradle_test": re.compile(r"\bgradle\b.*\btest\b|gradlew.*\btest\b"),
32
+ "maven_test": re.compile(r"\bmvn\b.*\btest\b|mvnw.*\btest\b"),
33
+ "exunit": re.compile(r"\bmix test\b"),
23
34
  }
24
35
 
25
36
  _PYTEST_RE = re.compile(r"(\d+) passed(?:.*?(\d+) failed)?(?:.*?(\d+) skipped)?")
26
37
  _JEST_RE = re.compile(r"Tests:\s+(?:(\d+) failed,\s+)?(\d+) passed(?:,\s+(\d+) skipped)?")
27
38
  _CARGO_RE = re.compile(r"(\d+) passed.*?(\d+) failed")
39
+ _VITEST_RE = re.compile(r"Tests\s+(\d+) passed(?:\s*\|\s*(\d+) failed)?(?:\s*\|\s*(\d+) skipped)?")
40
+ _RSPEC_RE = re.compile(r"(\d+) examples?,\s*(\d+) failures?(?:,\s*(\d+) pending)?")
41
+ _PHPUNIT_RE = re.compile(r"OK \((\d+) tests?|Tests:\s*(\d+).*?Failures:\s*(\d+)")
42
+ _DOTNET_RE = re.compile(r"Passed!\s+-\s+Failed:\s+(\d+),\s+Passed:\s+(\d+),\s+Skipped:\s+(\d+)")
43
+ _EXUNIT_RE = re.compile(r"(\d+) tests?,\s*(\d+) failures?(?:,\s*(\d+) excluded)?")
28
44
 
29
45
 
30
46
  def _detect_test(command: str) -> str | None:
@@ -40,9 +56,12 @@ def _parse_test_results(output: str, framework: str, exit_code: int) -> tuple[in
40
56
  m = _PYTEST_RE.search(output)
41
57
  if m:
42
58
  return int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0)
43
- elif framework == "jest":
44
- m = _JEST_RE.search(output)
59
+ elif framework in ("jest", "vitest"):
60
+ pat = _VITEST_RE if framework == "vitest" else _JEST_RE
61
+ m = pat.search(output)
45
62
  if m:
63
+ if framework == "vitest":
64
+ return int(m.group(1) or 0), int(m.group(2) or 0), int(m.group(3) or 0)
46
65
  return int(m.group(2) or 0), int(m.group(1) or 0), int(m.group(3) or 0)
47
66
  elif framework == "go_test":
48
67
  return output.count("--- PASS"), output.count("--- FAIL"), output.count("--- SKIP")
@@ -50,6 +69,24 @@ def _parse_test_results(output: str, framework: str, exit_code: int) -> tuple[in
50
69
  m = _CARGO_RE.search(output)
51
70
  if m:
52
71
  return int(m.group(1)), int(m.group(2)), 0
72
+ elif framework == "rspec":
73
+ m = _RSPEC_RE.search(output)
74
+ if m:
75
+ return int(m.group(1)) - int(m.group(2)), int(m.group(2)), int(m.group(3) or 0)
76
+ elif framework == "phpunit":
77
+ m = _PHPUNIT_RE.search(output)
78
+ if m:
79
+ if m.group(1):
80
+ return int(m.group(1)), 0, 0
81
+ return int(m.group(2) or 0) - int(m.group(3) or 0), int(m.group(3) or 0), 0
82
+ elif framework == "dotnet_test":
83
+ m = _DOTNET_RE.search(output)
84
+ if m:
85
+ return int(m.group(2)), int(m.group(1)), int(m.group(3))
86
+ elif framework == "exunit":
87
+ m = _EXUNIT_RE.search(output)
88
+ if m:
89
+ return int(m.group(1)) - int(m.group(2)), int(m.group(2)), int(m.group(3) or 0)
53
90
  if exit_code == 0:
54
91
  return 1, 0, 0
55
92
  return 0, 1, 0
@@ -91,7 +128,7 @@ def _process(line: str) -> None:
91
128
  return
92
129
  exit_code = entry.get("exit_code", 0)
93
130
  duration = entry.get("duration_ms", 0)
94
- output = entry.get("output", "")[:500]
131
+ output = entry.get("output", "")
95
132
  if SENSITIVE.search(output):
96
133
  output = "[redacted — contains sensitive content]"
97
134
  cwd = entry.get("cwd", "")
@@ -107,7 +144,10 @@ def _process(line: str) -> None:
107
144
  framework = _detect_test(command)
108
145
  if framework:
109
146
  passed, failed, skipped = _parse_test_results(output, framework, exit_code)
110
- base.emit("test_run", {
147
+ test_meta: dict = {
111
148
  "framework": framework, "passed": passed, "failed": failed,
112
149
  "skipped": skipped, "duration_ms": duration,
113
- })
150
+ }
151
+ if cwd:
152
+ test_meta["cwd"] = cwd
153
+ base.emit("test_run", test_meta)
@@ -45,9 +45,6 @@ IGNORE_PATTERNS = re.compile(
45
45
  )
46
46
 
47
47
 
48
- _MAX_DIFF_BYTES = 50_000
49
- _MAX_HUNK_LINES = 2_000
50
-
51
48
  _HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
52
49
  _DIFF_FILE_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)$")
53
50
 
@@ -60,7 +57,6 @@ def _parse_hunks(diff_text: str, include_lines: bool) -> list[dict[str, object]]
60
57
  """
61
58
  hunks: list[dict[str, object]] = []
62
59
  current: dict[str, object] | None = None
63
- total_lines = 0
64
60
  for line in diff_text.splitlines():
65
61
  header = _HUNK_HEADER_RE.match(line)
66
62
  if header:
@@ -81,10 +77,7 @@ def _parse_hunks(diff_text: str, include_lines: bool) -> list[dict[str, object]]
81
77
  continue
82
78
  if line.startswith("+++") or line.startswith("---"):
83
79
  continue
84
- if total_lines >= _MAX_HUNK_LINES:
85
- continue
86
80
  current["lines"].append(line) # type: ignore[union-attr]
87
- total_lines += 1
88
81
  if current is not None:
89
82
  hunks.append(current)
90
83
  return hunks
@@ -122,7 +115,7 @@ def _git_diff_hunks(repo: str, path: str, include_lines: bool) -> list[dict[str,
122
115
  ["git", "-C", repo, "diff", "--unified=0", "--", path],
123
116
  capture_output=True, text=True, timeout=5,
124
117
  )
125
- return _parse_hunks(result.stdout[:_MAX_DIFF_BYTES], include_lines)
118
+ return _parse_hunks(result.stdout, include_lines)
126
119
  except Exception:
127
120
  return []
128
121
 
@@ -136,7 +129,7 @@ def _git_show_file_hunks(
136
129
  ["git", "-C", repo, "show", "--format=", "--unified=0", sha],
137
130
  capture_output=True, text=True, timeout=10,
138
131
  )
139
- return _parse_show_hunks(result.stdout[:_MAX_DIFF_BYTES * 4], include_lines)
132
+ return _parse_show_hunks(result.stdout, include_lines)
140
133
  except Exception:
141
134
  return {}
142
135
 
@@ -160,25 +153,25 @@ def _git_diff_stats(repo: str, path: str) -> tuple[int, int]:
160
153
 
161
154
 
162
155
  def _git_diff_content(repo: str, path: str) -> str:
163
- """Get full diff content for a file, capped at 50KB."""
156
+ """Get full diff content for a file."""
164
157
  try:
165
158
  result = subprocess.run(
166
159
  ["git", "-C", repo, "diff", "--", path],
167
160
  capture_output=True, text=True, timeout=5,
168
161
  )
169
- return result.stdout[:_MAX_DIFF_BYTES]
162
+ return result.stdout
170
163
  except Exception:
171
164
  return ""
172
165
 
173
166
 
174
167
  def _git_show_diff(repo: str, sha: str) -> str:
175
- """Get full diff for a commit, capped at 50KB."""
168
+ """Get full diff for a commit."""
176
169
  try:
177
170
  result = subprocess.run(
178
171
  ["git", "-C", repo, "show", "--format=", sha],
179
172
  capture_output=True, text=True, timeout=10,
180
173
  )
181
- return result.stdout[:_MAX_DIFF_BYTES]
174
+ return result.stdout
182
175
  except Exception:
183
176
  return ""
184
177
 
@@ -239,14 +232,44 @@ class _Handler(FileSystemEventHandler):
239
232
  lang = Path(event.src_path).suffix.lstrip(".")
240
233
  base.emit("file_delete", {"path": path, "language": lang})
241
234
 
235
+ def on_moved(self, event: FileSystemEvent) -> None:
236
+ if event.is_directory:
237
+ return
238
+ src = event.src_path
239
+ dest = getattr(event, "dest_path", "")
240
+ if IGNORE_PATTERNS.search(src) and IGNORE_PATTERNS.search(dest or src):
241
+ return
242
+ old_path = self._relpath(src)
243
+ new_path = self._relpath(dest) if dest else old_path
244
+ lang = Path(dest or src).suffix.lstrip(".")
245
+ old_hash = self._hashes.pop(old_path, None)
246
+ if old_hash:
247
+ self._hashes[new_path] = old_hash
248
+ base.emit("file_rename", {
249
+ "old_path": old_path, "new_path": new_path, "language": lang,
250
+ })
251
+
252
+
253
+ def _read_branch(head_file: Path) -> str:
254
+ """Read current branch from .git/HEAD. Returns '' for detached HEAD."""
255
+ try:
256
+ content = head_file.read_text().strip()
257
+ if content.startswith("ref: refs/heads/"):
258
+ return content[16:]
259
+ return ""
260
+ except OSError:
261
+ return ""
262
+
242
263
 
243
264
  def _poll_git(watch_dir: str, stop: threading.Event) -> None:
244
- """Poll .git/refs for new commits every 2 seconds."""
265
+ """Poll .git/refs for new commits and HEAD for branch switches."""
245
266
  git_dir = Path(watch_dir) / ".git"
246
267
  if not git_dir.exists():
247
268
  return
248
269
  seen: set[str] = set()
249
270
  refs = git_dir / "refs" / "heads"
271
+ head_file = git_dir / "HEAD"
272
+ last_branch = _read_branch(head_file)
250
273
  while not stop.is_set():
251
274
  try:
252
275
  for ref in refs.iterdir():
@@ -257,12 +280,17 @@ def _poll_git(watch_dir: str, stop: threading.Event) -> None:
257
280
  _log_commit(watch_dir, sha)
258
281
  except OSError:
259
282
  pass
283
+ current_branch = _read_branch(head_file)
284
+ if current_branch and current_branch != last_branch and last_branch:
285
+ base.emit("git_branch_switch", {
286
+ "old_branch": last_branch, "new_branch": current_branch,
287
+ })
288
+ last_branch = current_branch
260
289
  stop.wait(2)
261
290
 
262
291
 
263
292
  def _log_commit(watch_dir: str, sha: str) -> None:
264
293
  try:
265
- # Single git call: subject\x00author\x00email\x00iso_date\x00parent_hash\x00full_body
266
294
  fmt = subprocess.run(
267
295
  ["git", "-C", watch_dir, "log", "-1",
268
296
  "--format=%s%x00%an%x00%ae%x00%ai%x00%P%x00%B", sha],
@@ -275,12 +303,22 @@ def _log_commit(watch_dir: str, sha: str) -> None:
275
303
  committed_at = parts[3].strip() if len(parts) > 3 else ""
276
304
  parent_hash = parts[4].strip()[:7] if len(parts) > 4 else ""
277
305
  body = parts[5].strip() if len(parts) > 5 else ""
278
- files = subprocess.run(
279
- ["git", "-C", watch_dir, "diff-tree", "--no-commit-id", "-r", "--name-only", sha],
306
+ status_lines = subprocess.run(
307
+ ["git", "-C", watch_dir, "diff-tree", "--no-commit-id", "-r", "--name-status", sha],
280
308
  capture_output=True, text=True, timeout=5,
281
309
  ).stdout.strip().splitlines()
310
+ files: list[str] = []
311
+ file_statuses: dict[str, str] = {}
312
+ for sl in status_lines:
313
+ sl_parts = sl.split("\t", 1)
314
+ if len(sl_parts) == 2:
315
+ file_statuses[sl_parts[1]] = sl_parts[0]
316
+ files.append(sl_parts[1])
317
+ elif sl.strip():
318
+ files.append(sl.strip())
282
319
  except Exception:
283
- subject, author, author_email, committed_at, parent_hash, body, files = "", "", "", "", "", "", []
320
+ subject, author, author_email, committed_at, parent_hash, body = "", "", "", "", "", ""
321
+ files, file_statuses = [], {}
284
322
  meta: dict[str, object] = {
285
323
  "hash": sha[:7], "message": subject, "files_changed": files,
286
324
  "author": author, "author_email": author_email, "committed_at": committed_at,
@@ -288,7 +326,12 @@ def _log_commit(watch_dir: str, sha: str) -> None:
288
326
  if parent_hash:
289
327
  meta["parent_hash"] = parent_hash
290
328
  if body and body != subject:
291
- meta["body"] = body[:2000]
329
+ meta["body"] = body
330
+ if file_statuses:
331
+ meta["file_statuses"] = file_statuses
332
+ branch = _read_branch(Path(watch_dir) / ".git" / "HEAD")
333
+ if branch:
334
+ meta["branch"] = branch
292
335
  include_lines = base.is_content_captured()
293
336
  file_hunks = _git_show_file_hunks(watch_dir, sha, include_lines)
294
337
  if file_hunks:
@@ -1577,8 +1577,6 @@ def cmd_log(args: argparse.Namespace) -> None:
1577
1577
  fake = _ap.Namespace(session_id=sid, local=False)
1578
1578
  if action == "push":
1579
1579
  cmd_push(fake)
1580
- elif action == "view":
1581
- cmd_view(fake)
1582
1580
  return
1583
1581
  sessions = store.list_sessions()
1584
1582
  if not sessions:
@@ -36,20 +36,19 @@ def build(session_id: str) -> dict[str, int]:
36
36
  db.execute(
37
37
  "DELETE FROM causal_links WHERE source_id IN "
38
38
  "(SELECT id FROM events WHERE session_id = ?)", (session_id,))
39
- stats["causal"] += _link(db, session_id, "llm_prompt", "llm_completion",
40
- "RECEIVED", 60, match_model=True)
41
- stats["causal"] += _link(db, session_id, "llm_completion", "file_edit",
42
- "INFORMED", 60)
43
- stats["causal"] += _link(db, session_id, "web_search", "web_visit",
44
- "LED_TO", 120)
45
- stats["causal"] += _link(db, session_id, "browser_search", "browser_visit",
46
- "LED_TO", 120)
39
+ stats["causal"] += _link_nearest(db, session_id, "llm_prompt",
40
+ "llm_completion", "RECEIVED", "model")
41
+ stats["causal"] += _link_until_next(db, session_id, "llm_completion",
42
+ "file_edit", "INFORMED")
43
+ stats["causal"] += _link_until_next(db, session_id, "web_search",
44
+ "web_visit", "LED_TO")
45
+ stats["causal"] += _link_until_next(db, session_id, "browser_search",
46
+ "browser_visit", "LED_TO")
47
47
  stats["causal"] += _link_pasted(db, session_id)
48
- # Agent causal links (OpenClaw / agent gateways)
49
- stats["causal"] += _link(db, session_id, "agent_prompt", "agent_completion",
50
- "RECEIVED", 120, match_model=True)
51
- stats["causal"] += _link(db, session_id, "agent_completion", "file_edit",
52
- "INFORMED", 60)
48
+ stats["causal"] += _link_nearest(db, session_id, "agent_prompt",
49
+ "agent_completion", "RECEIVED", "model")
50
+ stats["causal"] += _link_until_next(db, session_id, "agent_completion",
51
+ "file_edit", "INFORMED")
53
52
 
54
53
  # Resources
55
54
  for e in events:
@@ -95,39 +94,87 @@ def build(session_id: str) -> dict[str, int]:
95
94
  return stats
96
95
 
97
96
 
98
- def _link(
97
+ def _link_nearest(
99
98
  db: object, sid: str, src_type: str, tgt_type: str,
100
- rel: str, window_sec: int, match_model: bool = False,
99
+ rel: str, match_field: str | None = None,
101
100
  ) -> int:
102
- model_clause = (
103
- "AND json_extract(mp_json(s.metadata), '$.model') = json_extract(mp_json(t.metadata), '$.model')"
104
- if match_model else ""
105
- )
106
- sql = f"""
107
- INSERT OR IGNORE INTO causal_links (source_id, target_id, type)
108
- SELECT s.id, t.id, ?
109
- FROM events s JOIN events t ON t.session_id = s.session_id
110
- WHERE s.session_id = ? AND s.type = ? AND t.type = ?
111
- AND t.timestamp > s.timestamp
112
- AND (t.timestamp - s.timestamp) <= ?
113
- {model_clause}
114
- """
115
- return db.execute(sql, (rel, sid, src_type, tgt_type, window_sec)).rowcount
101
+ """1:1 nearest-neighbor pairing. No time window."""
102
+ rows = db.execute(
103
+ "SELECT id, type, timestamp, metadata FROM events "
104
+ "WHERE session_id = ? AND type IN (?, ?) ORDER BY timestamp",
105
+ (sid, src_type, tgt_type),
106
+ ).fetchall()
107
+ claimed: set[str] = set()
108
+ pairs: list[tuple[str, str, str]] = []
109
+ for src in rows:
110
+ if src["type"] != src_type:
111
+ continue
112
+ src_val = _decompress_meta(src["metadata"]).get(match_field) if match_field else None
113
+ for tgt in rows:
114
+ if tgt["type"] != tgt_type or tgt["id"] in claimed or tgt["timestamp"] <= src["timestamp"]:
115
+ continue
116
+ if match_field and _decompress_meta(tgt["metadata"]).get(match_field) != src_val:
117
+ continue
118
+ pairs.append((src["id"], tgt["id"], rel))
119
+ claimed.add(tgt["id"])
120
+ break
121
+ if pairs:
122
+ db.executemany(
123
+ "INSERT OR IGNORE INTO causal_links (source_id, target_id, type) "
124
+ "VALUES (?, ?, ?)", pairs)
125
+ return len(pairs)
126
+
127
+
128
+ def _link_until_next(
129
+ db: object, sid: str, src_type: str, tgt_type: str, rel: str,
130
+ ) -> int:
131
+ """Link source to all targets until the next source event."""
132
+ rows = db.execute(
133
+ "SELECT id, type FROM events "
134
+ "WHERE session_id = ? AND type IN (?, ?) ORDER BY timestamp",
135
+ (sid, src_type, tgt_type),
136
+ ).fetchall()
137
+ pairs: list[tuple[str, str, str]] = []
138
+ current_src: str | None = None
139
+ for ev in rows:
140
+ if ev["type"] == src_type:
141
+ current_src = ev["id"]
142
+ elif current_src:
143
+ pairs.append((current_src, ev["id"], rel))
144
+ if pairs:
145
+ db.executemany(
146
+ "INSERT OR IGNORE INTO causal_links (source_id, target_id, type) "
147
+ "VALUES (?, ?, ?)", pairs)
148
+ return len(pairs)
116
149
 
117
150
 
118
151
  def _link_pasted(db: object, sid: str) -> int:
119
- """browser_copy → file_edit: ≤30s, content length within 20%."""
120
- sql = """
121
- INSERT OR IGNORE INTO causal_links (source_id, target_id, type, confidence)
122
- SELECT s.id, t.id, 'PASTED_FROM', 0.7
123
- FROM events s JOIN events t ON t.session_id = s.session_id
124
- WHERE s.session_id = ? AND s.type = 'browser_copy' AND t.type = 'file_edit'
125
- AND t.timestamp > s.timestamp AND (t.timestamp - s.timestamp) <= 30
126
- AND abs(json_extract(mp_json(t.metadata), '$.lines_added') * 40.0
127
- - json_extract(mp_json(s.metadata), '$.text_length'))
128
- < json_extract(mp_json(s.metadata), '$.text_length') * 0.2
129
- """
130
- return db.execute(sql, (sid,)).rowcount
152
+ """browser_copy → nearest file_edit with content length within 20%."""
153
+ rows = db.execute(
154
+ "SELECT id, type, timestamp, metadata FROM events "
155
+ "WHERE session_id = ? AND type IN ('browser_copy', 'file_edit') "
156
+ "ORDER BY timestamp", (sid,),
157
+ ).fetchall()
158
+ claimed: set[str] = set()
159
+ pairs: list[tuple[str, str]] = []
160
+ for src in rows:
161
+ if src["type"] != "browser_copy":
162
+ continue
163
+ src_len = _decompress_meta(src["metadata"]).get("text_length", 0)
164
+ if not src_len:
165
+ continue
166
+ for tgt in rows:
167
+ if tgt["type"] != "file_edit" or tgt["id"] in claimed or tgt["timestamp"] <= src["timestamp"]:
168
+ continue
169
+ if abs(_decompress_meta(tgt["metadata"]).get("lines_added", 0) * 40 - src_len) < src_len * 0.2:
170
+ pairs.append((src["id"], tgt["id"]))
171
+ claimed.add(tgt["id"])
172
+ break
173
+ if pairs:
174
+ db.executemany(
175
+ "INSERT OR IGNORE INTO causal_links (source_id, target_id, type, confidence) "
176
+ "VALUES (?, ?, 'PASTED_FROM', 0.7)", pairs)
177
+ return len(pairs)
131
178
 
132
179
 
133
180
  def _link_action_resources(db: object, sid: str) -> None:
@@ -76,6 +76,8 @@ _TYPE_MAP = {
76
76
  # Worktree
77
77
  "WorktreeCreate": "worktree_create",
78
78
  "WorktreeRemove": "worktree_remove",
79
+ # Notifications
80
+ "Notification": "notification",
79
81
  }
80
82
 
81
83
  _TOOL = "claude_code"
@@ -113,20 +115,19 @@ _META_EXTRACTORS = {
113
115
  "tool": _TOOL, "tool_name": d.get("tool_name", "unknown"),
114
116
  "success": False, "is_interrupt": d.get("is_interrupt", False),
115
117
  "tool_input": d.get("tool_input") or {},
116
- "error": str(d.get("error", ""))[:200],
118
+ "error": d.get("error", ""),
117
119
  },
118
120
  "SubagentStart": lambda d: {"tool": _TOOL, "agent_type": d.get("agent_type", "unknown"), "agent_id": d.get("agent_id", "")},
119
121
  "SubagentStop": lambda d: {
120
122
  "tool": _TOOL, "agent_type": d.get("agent_type", "unknown"), "agent_id": d.get("agent_id", ""),
121
123
  "last_assistant_message": d.get("last_assistant_message", ""),
122
- "last_message_preview": str(d.get("last_assistant_message", ""))[:200],
123
124
  },
124
125
  "TaskCreated": lambda d: {"tool": _TOOL, "task_id": d.get("task_id", ""), "subject": d.get("task_subject", "")},
125
126
  "TaskCompleted": lambda d: {"tool": _TOOL, "task_id": d.get("task_id", "")},
126
127
  "SessionStart": lambda d: {"tool": _TOOL, "session_id": d.get("session_id", ""), "cwd": d.get("cwd", "")},
127
128
  "SessionEnd": lambda d: {"tool": _TOOL, "session_id": d.get("session_id", "")},
128
129
  "Stop": lambda d: {"tool": _TOOL},
129
- "StopFailure": lambda d: {"tool": _TOOL, "error": str(d.get("error", ""))[:200]},
130
+ "StopFailure": lambda d: {"tool": _TOOL, "error": d.get("error", "")},
130
131
  "CwdChanged": lambda d: {
131
132
  "tool": _TOOL, "cwd": d.get("cwd", ""),
132
133
  # NOTE: fires for both human `cd` and Claude tool use — caller is ambiguous
@@ -140,6 +141,12 @@ _META_EXTRACTORS = {
140
141
  "ElicitationResult": lambda d: {"tool": _TOOL},
141
142
  "WorktreeCreate": lambda d: {"tool": _TOOL, "worktree_path": d.get("worktree_path", "")},
142
143
  "WorktreeRemove": lambda d: {"tool": _TOOL, "worktree_path": d.get("worktree_path", "")},
144
+ "Notification": lambda d: {
145
+ "tool": _TOOL,
146
+ "title": d.get("title", ""),
147
+ "message": d.get("message", d.get("text", ""))[:1000],
148
+ "notification_type": d.get("type", d.get("notification_type", "")),
149
+ },
143
150
  }
144
151
 
145
152
 
@@ -162,6 +162,10 @@ if command -v jq >/dev/null 2>&1; then
162
162
  TYPE=$(echo "$EVENT" | sed 's/Elicitation$/mcp_elicitation/;s/ElicitationResult/mcp_elicitation_result/')
163
163
  META='{"tool":"claude_code"}'
164
164
  ;;
165
+ Notification)
166
+ TYPE="notification"
167
+ META=$(echo "$INPUT" | jq -c '{tool: "claude_code", title: (.title // ""), message: ((.message // .text // "")[:1000]), notification_type: (.type // .notification_type // "")}' 2>/dev/null || echo '{"tool":"claude_code"}')
168
+ ;;
165
169
  *)
166
170
  TYPE="claude_code_event"
167
171
  META="{\"event\":\"$EVENT\"}"
@@ -38,6 +38,7 @@ HOOK_EVENTS = [
38
38
  "ElicitationResult", # user responded to MCP
39
39
  "WorktreeCreate", # git worktree created (parallel agent)
40
40
  "WorktreeRemove", # git worktree removed
41
+ "Notification", # system notifications (recap, compaction summary, etc.)
41
42
  ]
42
43
 
43
44
  # --- Codex CLI ---