methodproof 0.7.38__tar.gz → 0.8.1__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 (92) hide show
  1. methodproof-0.8.1/.code-review-graph/.gitignore +3 -0
  2. methodproof-0.8.1/.code-review-graph/graph.db +0 -0
  3. {methodproof-0.7.38 → methodproof-0.8.1}/.github/workflows/ci.yml +6 -6
  4. {methodproof-0.7.38 → methodproof-0.8.1}/CHANGELOG.md +35 -0
  5. {methodproof-0.7.38 → methodproof-0.8.1}/PKG-INFO +2 -1
  6. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/__init__.py +1 -1
  7. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/base.py +40 -1
  8. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/terminal.py +45 -5
  9. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/watcher.py +62 -19
  10. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/cli.py +76 -10
  11. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/crypto.py +17 -0
  12. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/e2e.py +74 -30
  13. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/claude_code.py +4 -7
  14. methodproof-0.8.1/methodproof/migrate_db.py +161 -0
  15. methodproof-0.8.1/methodproof/repos.py +70 -0
  16. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/store.py +77 -6
  17. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/sync.py +10 -1
  18. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/log.py +106 -12
  19. methodproof-0.8.1/methodproof/tui/theme.py +156 -0
  20. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/viewer.py +1 -1
  21. {methodproof-0.7.38 → methodproof-0.8.1}/pyproject.toml +2 -2
  22. {methodproof-0.7.38 → methodproof-0.8.1}/tests/conftest.py +1 -0
  23. methodproof-0.8.1/tests/test_repos.py +73 -0
  24. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_security.py +102 -1
  25. {methodproof-0.7.38 → methodproof-0.8.1}/uv.lock +168 -1
  26. methodproof-0.7.38/methodproof/migrate_db.py +0 -41
  27. methodproof-0.7.38/methodproof/repos.py +0 -25
  28. methodproof-0.7.38/methodproof/tui/theme.py +0 -106
  29. {methodproof-0.7.38 → methodproof-0.8.1}/.gitignore +0 -0
  30. {methodproof-0.7.38 → methodproof-0.8.1}/LICENSE +0 -0
  31. {methodproof-0.7.38 → methodproof-0.8.1}/README.md +0 -0
  32. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/__main__.py +0 -0
  33. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/_daemon.py +0 -0
  34. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/__init__.py +0 -0
  35. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/agents/music.py +0 -0
  36. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/analysis.py +0 -0
  37. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/binding.py +0 -0
  38. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/bip39.py +0 -0
  39. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/bridge.py +0 -0
  40. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/config.py +0 -0
  41. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/graph.py +0 -0
  42. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hook.py +0 -0
  43. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/__init__.py +0 -0
  44. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/claude_code.sh +0 -0
  45. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/cline_hook.sh +0 -0
  46. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/codex_hook.sh +0 -0
  47. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/gemini_hook.sh +0 -0
  48. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/install.py +0 -0
  49. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/kiro_hook.sh +0 -0
  50. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/mcp_register.py +0 -0
  51. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/openclaw/HOOK.md +0 -0
  52. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/openclaw/handler.ts +0 -0
  53. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/openclaw_install.py +0 -0
  54. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/opencode_plugin.js +0 -0
  55. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/hooks/wrappers.py +0 -0
  56. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/integrity.py +0 -0
  57. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/kdf.py +0 -0
  58. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/keychain.py +0 -0
  59. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/live.py +0 -0
  60. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/lock.py +0 -0
  61. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/mcp.py +0 -0
  62. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/proxy.py +0 -0
  63. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/proxy_daemon.py +0 -0
  64. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/skills/methodproof/SKILL.md +0 -0
  65. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/__init__.py +0 -0
  66. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/consent.py +0 -0
  67. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/init.py +0 -0
  68. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/login_success.py +0 -0
  69. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/review.py +0 -0
  70. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/start.py +0 -0
  71. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/tui/status.py +0 -0
  72. {methodproof-0.7.38 → methodproof-0.8.1}/methodproof/wordlist.py +0 -0
  73. {methodproof-0.7.38 → methodproof-0.8.1}/test_windows_compat.py +0 -0
  74. {methodproof-0.7.38 → methodproof-0.8.1}/tests/__init__.py +0 -0
  75. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_analysis.py +0 -0
  76. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_auth.py +0 -0
  77. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_config.py +0 -0
  78. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_helpers.py +0 -0
  79. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_session.py +0 -0
  80. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_share.py +0 -0
  81. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_start.py +0 -0
  82. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_cli_update.py +0 -0
  83. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_e2e_integration.py +0 -0
  84. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_graph.py +0 -0
  85. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_hooks.py +0 -0
  86. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_live.py +0 -0
  87. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_openclaw_hooks.py +0 -0
  88. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_profiles.py +0 -0
  89. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_store.py +0 -0
  90. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_sync.py +0 -0
  91. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_viewer.py +0 -0
  92. {methodproof-0.7.38 → methodproof-0.8.1}/tests/test_wrappers.py +0 -0
@@ -0,0 +1,3 @@
1
+ # Auto-generated by code-review-graph — do not commit database files.
2
+ # The graph.db contains absolute paths and code structure metadata.
3
+ *
@@ -11,10 +11,10 @@ jobs:
11
11
  test:
12
12
  runs-on: ubuntu-latest
13
13
  steps:
14
- - uses: actions/checkout@v4
15
- - uses: astral-sh/setup-uv@v4
14
+ - uses: actions/checkout@v5
15
+ - uses: astral-sh/setup-uv@v7
16
16
  with: { version: "latest" }
17
- - uses: actions/setup-python@v5
17
+ - uses: actions/setup-python@v6
18
18
  with: { python-version: "3.12" }
19
19
  - run: uv sync --frozen
20
20
  - run: uv run pytest tests/ -v --tb=short
@@ -26,10 +26,10 @@ jobs:
26
26
  permissions:
27
27
  id-token: write
28
28
  steps:
29
- - uses: actions/checkout@v4
30
- - uses: astral-sh/setup-uv@v4
29
+ - uses: actions/checkout@v5
30
+ - uses: astral-sh/setup-uv@v7
31
31
  with: { version: "latest" }
32
- - uses: actions/setup-python@v5
32
+ - uses: actions/setup-python@v6
33
33
  with: { python-version: "3.12" }
34
34
  - run: uv build
35
35
  - uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,5 +1,40 @@
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
+
22
+ ## [0.8.0] — 2026-04-15
23
+
24
+ ### Added
25
+ - **SQLCipher encryption-at-rest for the local database** — the CLI's SQLite store is now transparently re-keyed with SQLCipher on first login, layered underneath the existing field-level AES on sensitive metadata. The db_key is derived from the master entropy via HKDF (`derive_master` → `derive_db_key`), never leaves the OS keychain, and is wired into `store._db()` via `PRAGMA key` before any other statement.
26
+ - **One-shot plaintext → encrypted migration** — `migrate_db.migrate_to_sqlcipher()` uses the `sqlcipher_export()` recipe, row-count-verifies every table, atomically swaps the original DB to `methodproof.db.plaintext.bak` (kept for recovery), cleans up stale WAL/SHM artifacts, and writes a `methodproof.db.encrypted` sentinel. Runs once on first login for existing users, idempotent on re-run.
27
+ - **Daemon-liveness guard** — `DaemonActiveError` is raised if a recording daemon is still holding FDs on the plaintext DB. Callers must `mp stop` first.
28
+ - **`mp e2e release-all`** — releases every synced session with E2E-encrypted fields in one pass. Skips sessions with nothing to release, reports released/skipped/failed counts.
29
+ - **`decrypt_metadata_safe`** — new read-path helper in `crypto.py` that silently leaves fields encrypted with a different key (user E2E vs master) as ciphertext, so mixed-key sessions render correctly without crashing.
30
+ - **Auto-decrypt on read** — `store.get_events(decrypt=True)` and `get_session_events` now auto-decrypt sensitive metadata when the master db_key is available in the keychain. Sync push and `e2e release` still read ciphertext verbatim (`decrypt=False`).
31
+
32
+ ### Fixed
33
+ - **`mp push` crash on multi-session list** — `s["created_at"]` is stored as SQLite `REAL` (float epoch), but the unsynced-session listing sliced it as an ISO string and raised `TypeError: 'float' object is not subscriptable`. Now formatted with `datetime.fromtimestamp(...).strftime("%Y-%m-%d")`.
34
+
35
+ ### Why
36
+ Field-level AES protects six sensitive metadata fields, but everything else (event types, timestamps, file paths, session structure) sat in plaintext SQLite on disk. SQLCipher closes that gap without changing the API surface: `sqlcipher3.dbapi2` is drop-in compatible with stdlib `sqlite3`, so the rest of the store is unchanged. The two layers compose: SQLCipher protects data at rest end-to-end, field-level AES adds defense-in-depth for the most sensitive fields even if the db_key is compromised.
37
+
3
38
  ## [0.7.38] — 2026-04-12
4
39
 
5
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.7.38
3
+ Version: 0.8.1
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
@@ -8,6 +8,7 @@ Requires-Python: >=3.11
8
8
  Requires-Dist: cryptography>=43.0
9
9
  Requires-Dist: keyring>=25.0
10
10
  Requires-Dist: rich>=13.7
11
+ Requires-Dist: sqlcipher3>=0.6
11
12
  Requires-Dist: textual>=0.59
12
13
  Requires-Dist: watchdog>=4.0
13
14
  Requires-Dist: websocket-client>=1.7
@@ -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.
@@ -108,9 +126,19 @@ def init(session_id: str, live: bool = False, verbose: bool = False, streaming:
108
126
  _verbose = verbose
109
127
  _streaming = streaming
110
128
  _prev_hash = "genesis"
111
- from methodproof import config
129
+ from methodproof import config, store
112
130
  cfg = config.load()
113
131
  _e2e_key = _load_encryption_key(cfg)
132
+ # Daemon is a fresh process — wire the SQLCipher key (always master-derived
133
+ # db_key, never user E2E) into the store before any DB write.
134
+ if store._encrypted_flag_path().exists():
135
+ account_id = cfg.get("account_id", "")
136
+ if account_id and cfg.get("master_key_fingerprint"):
137
+ from methodproof.keychain import load_secret
138
+ from methodproof.kdf import derive_master, derive_db_key
139
+ entropy = load_secret(account_id)
140
+ if entropy:
141
+ store.set_db_key(derive_db_key(derive_master(entropy), account_id))
114
142
  _capture = cfg.get("capture", {})
115
143
  _journal_mode = cfg.get("journal_mode", False)
116
144
  _account_id = cfg.get("account_id", "")
@@ -132,6 +160,13 @@ def is_content_captured() -> bool:
132
160
  return bool(_capture.get("code_capture", False))
133
161
 
134
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
+
135
170
  def emit(event_type: str, metadata: dict[str, Any]) -> None:
136
171
  if not _initialized:
137
172
  log("warning", "emit.before_init", type=event_type)
@@ -216,6 +251,10 @@ def _stream_event(entry: dict[str, Any]) -> None:
216
251
  detail = f'{meta.get("path", "?")} ({meta.get("size", 0)}B)'
217
252
  elif etype == "file_delete":
218
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", "?")}'
219
258
  elif etype == "terminal_cmd":
220
259
  detail = f'{meta.get("command", "?")[:60]} → exit {meta.get("exit_code", "?")}'
221
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:
@@ -985,14 +985,26 @@ def _setup_master_key(cfg: dict) -> None:
985
985
  if not account_id:
986
986
  return
987
987
  from methodproof.keychain import has_secret, store_secret, load_secret
988
+ from methodproof import store as _store
988
989
  if has_secret(account_id):
989
- # Already set up — ensure fingerprint is in config
990
+ # Already set up — ensure fingerprint is in config and key is wired
991
+ from methodproof.kdf import derive_master, derive_db_key
992
+ from methodproof.crypto import fingerprint
993
+ master = derive_master(load_secret(account_id))
994
+ db_key = derive_db_key(master, account_id)
990
995
  if not cfg.get("master_key_fingerprint"):
991
- from methodproof.kdf import derive_master, derive_db_key
992
- from methodproof.crypto import fingerprint
993
- master = derive_master(load_secret(account_id))
994
- cfg["master_key_fingerprint"] = fingerprint(derive_db_key(master, account_id))
996
+ cfg["master_key_fingerprint"] = fingerprint(db_key)
995
997
  config.save(cfg)
998
+ if _store._encrypted_flag_path().exists():
999
+ _store.set_db_key(db_key)
1000
+ else:
1001
+ # Existing user, plaintext DB — one-shot upgrade to SQLCipher
1002
+ from methodproof.migrate_db import migrate_to_sqlcipher, DaemonActiveError
1003
+ try:
1004
+ if migrate_to_sqlcipher(db_key):
1005
+ print(" Encrypted local database with SQLCipher.\n")
1006
+ except DaemonActiveError as exc:
1007
+ print(f" ⚠ {exc}")
996
1008
  return
997
1009
 
998
1010
  # Check if user has a recovery phrase (returning user, new device)
@@ -1025,13 +1037,21 @@ def _setup_master_key(cfg: dict) -> None:
1025
1037
  print(f" │ {D}on a new device. Store it somewhere safe.{R} │")
1026
1038
  print(f" └──────────────────────────────────────────────────┘\n")
1027
1039
 
1028
- # Encrypt any existing plaintext events
1040
+ # Encrypt any existing plaintext events (field-level, defense-in-depth)
1029
1041
  db_key = derive_db_key(master, account_id)
1030
- from methodproof.migrate_db import migrate_encrypt
1042
+ from methodproof.migrate_db import migrate_encrypt, migrate_to_sqlcipher
1031
1043
  count = migrate_encrypt(db_key)
1032
1044
  if count:
1033
1045
  print(f" Encrypted {count} existing events.\n")
1034
1046
 
1047
+ # Whole-database SQLCipher encryption — runs once, idempotent
1048
+ from methodproof.migrate_db import DaemonActiveError
1049
+ try:
1050
+ if migrate_to_sqlcipher(db_key):
1051
+ print(" Encrypted local database with SQLCipher.\n")
1052
+ except DaemonActiveError as exc:
1053
+ print(f" ⚠ {exc}")
1054
+
1035
1055
 
1036
1056
  def _recover_master_key(cfg: dict, account_id: str) -> None:
1037
1057
  """Prompt for recovery phrase to restore master key on new device."""
@@ -1049,9 +1069,55 @@ def _recover_master_key(cfg: dict, account_id: str) -> None:
1049
1069
  return
1050
1070
  from methodproof.keychain import store_secret
1051
1071
  store_secret(account_id, entropy)
1072
+ # Wire the recovered key into the SQLCipher connection so the next DB read works
1073
+ from methodproof.kdf import derive_master, derive_db_key
1074
+ from methodproof import store as _store
1075
+ if _store._encrypted_flag_path().exists():
1076
+ _store.set_db_key(derive_db_key(derive_master(entropy), account_id))
1052
1077
  print(" Master key restored.\n")
1053
1078
 
1054
1079
 
1080
+ def _wire_db_encryption(cfg: dict) -> None:
1081
+ """Wire the SQLCipher key into store before any subcommand touches the DB.
1082
+
1083
+ Called once at the top of `main()`. Three cases:
1084
+ 1. Encrypted DB + key available → wire the key.
1085
+ 2. Plaintext DB + logged-in user with master key → one-shot upgrade
1086
+ migration to SQLCipher (the post-release upgrade path).
1087
+ 3. Anything else (logged out, no key) → no-op; capture continues against
1088
+ the plaintext DB until the user logs in.
1089
+ """
1090
+ from methodproof import store as _store
1091
+ account_id = cfg.get("account_id", "")
1092
+ if not account_id:
1093
+ return
1094
+ try:
1095
+ from methodproof.keychain import load_secret
1096
+ from methodproof.kdf import derive_master, derive_db_key
1097
+ entropy = load_secret(account_id)
1098
+ if not entropy:
1099
+ return
1100
+ db_key = derive_db_key(derive_master(entropy), account_id)
1101
+ except Exception:
1102
+ return
1103
+
1104
+ if _store._encrypted_flag_path().exists():
1105
+ _store.set_db_key(db_key)
1106
+ return
1107
+
1108
+ # Plaintext DB + key available — one-shot upgrade
1109
+ try:
1110
+ from methodproof.migrate_db import migrate_to_sqlcipher, DaemonActiveError
1111
+ if migrate_to_sqlcipher(db_key):
1112
+ print(" Encrypted local database with SQLCipher.\n")
1113
+ except DaemonActiveError:
1114
+ # Daemon is recording — defer migration to next CLI run after `mp stop`.
1115
+ # Don't print noise on every command; the upgrade will happen later.
1116
+ return
1117
+ except Exception as exc:
1118
+ sys.stderr.write(f"[methodproof] sqlcipher migration deferred: {exc}\n")
1119
+
1120
+
1055
1121
  def _is_daemon_alive() -> bool:
1056
1122
  """Check if the recording daemon is still running (not a reused PID)."""
1057
1123
  if not PIDFILE.exists():
@@ -1511,8 +1577,6 @@ def cmd_log(args: argparse.Namespace) -> None:
1511
1577
  fake = _ap.Namespace(session_id=sid, local=False)
1512
1578
  if action == "push":
1513
1579
  cmd_push(fake)
1514
- elif action == "view":
1515
- cmd_view(fake)
1516
1580
  return
1517
1581
  sessions = store.list_sessions()
1518
1582
  if not sessions:
@@ -1844,7 +1908,7 @@ def cmd_push(args: argparse.Namespace) -> None:
1844
1908
  print(f"Found {len(unsynced)} unsynced sessions:\n")
1845
1909
  for s in unsynced:
1846
1910
  events = len(store.get_events(s["id"]))
1847
- date = s["created_at"][:10] if s.get("created_at") else "?"
1911
+ date = datetime.fromtimestamp(s["created_at"]).strftime("%Y-%m-%d") if s.get("created_at") else "?"
1848
1912
  print(f" {s['id'][:8]} {date} {events} events")
1849
1913
  print()
1850
1914
  answer = input(f"Push all {len(unsynced)}? [Y/n] ").strip().lower()
@@ -2342,6 +2406,7 @@ def main() -> None:
2342
2406
  e2e_sub.add_parser("recover", help="Recover key from passphrase")
2343
2407
  e2e_rel = e2e_sub.add_parser("release", help="Release a session from E2E encryption")
2344
2408
  e2e_rel.add_argument("session_id", help="Session ID to release")
2409
+ e2e_sub.add_parser("release-all", help="Release every synced session from E2E encryption")
2345
2410
  sub.add_parser("intro", help="Show the MethodProof intro")
2346
2411
  sub.add_parser("help", help="Show command reference")
2347
2412
  sub.add_parser("mcp-serve", help="Run MCP server (used by Claude Code)")
@@ -2381,5 +2446,6 @@ def main() -> None:
2381
2446
  _update_check()
2382
2447
 
2383
2448
  if args.cmd not in ("help", "update"):
2449
+ _wire_db_encryption(config.load())
2384
2450
  store.init_db()
2385
2451
  fn(args)
@@ -44,3 +44,20 @@ def encrypt_metadata(metadata: dict, key: bytes) -> dict:
44
44
  if field in metadata and isinstance(metadata[field], str):
45
45
  metadata[field] = encrypt_field(metadata[field], key)
46
46
  return metadata
47
+
48
+
49
+ def decrypt_metadata_safe(metadata: dict, key: bytes) -> dict:
50
+ """Decrypt sensitive fields in place. Silently leaves fields that fail
51
+ (wrong key, tampering) as ciphertext — never raises. Used on the read path
52
+ where a field encrypted with a *different* key (user E2E vs master) must
53
+ still render as-is rather than break rendering."""
54
+ if AESGCM is None:
55
+ return metadata
56
+ for field in SENSITIVE_FIELDS:
57
+ val = metadata.get(field)
58
+ if isinstance(val, str) and val.startswith("e2e:v1:"):
59
+ try:
60
+ metadata[field] = decrypt_field(val, key)
61
+ except Exception:
62
+ pass
63
+ return metadata