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.
- {methodproof-0.8.0 → methodproof-0.8.2}/CHANGELOG.md +19 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/PKG-INFO +1 -1
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/__init__.py +1 -1
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/base.py +29 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/terminal.py +45 -5
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/watcher.py +62 -19
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/cli.py +0 -2
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/graph.py +88 -41
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/claude_code.py +10 -3
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/claude_code.sh +4 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/install.py +1 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/log.py +106 -12
- methodproof-0.8.2/methodproof/tui/theme.py +156 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/pyproject.toml +1 -1
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_graph.py +5 -4
- methodproof-0.8.0/methodproof/tui/theme.py +0 -106
- {methodproof-0.8.0 → methodproof-0.8.2}/.github/workflows/ci.yml +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/.gitignore +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/LICENSE +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/README.md +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/__main__.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/_daemon.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/agents/music.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/analysis.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/binding.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/bip39.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/bridge.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/config.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/crypto.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/e2e.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hook.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/integrity.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/kdf.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/keychain.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/live.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/lock.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/mcp.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/migrate_db.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/proxy.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/repos.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/store.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/sync.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/__init__.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/consent.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/init.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/login_success.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/review.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/start.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/tui/status.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/viewer.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/methodproof/wordlist.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/test_windows_compat.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/__init__.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/conftest.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_analysis.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_auth.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_config.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_helpers.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_session.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_share.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_start.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_cli_update.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_e2e_integration.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_hooks.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_live.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_profiles.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_repos.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_security.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_store.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_sync.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_viewer.py +0 -0
- {methodproof-0.8.0 → methodproof-0.8.2}/tests/test_wrappers.py +0 -0
- {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
|
|
@@ -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
|
|
44
|
-
|
|
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", "")
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
279
|
-
["git", "-C", watch_dir, "diff-tree", "--no-commit-id", "-r", "--name-
|
|
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
|
|
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
|
|
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"] +=
|
|
40
|
-
|
|
41
|
-
stats["causal"] +=
|
|
42
|
-
|
|
43
|
-
stats["causal"] +=
|
|
44
|
-
|
|
45
|
-
stats["causal"] +=
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
97
|
+
def _link_nearest(
|
|
99
98
|
db: object, sid: str, src_type: str, tgt_type: str,
|
|
100
|
-
rel: str,
|
|
99
|
+
rel: str, match_field: str | None = None,
|
|
101
100
|
) -> int:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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":
|
|
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":
|
|
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 ---
|