methodproof 0.7.37__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {methodproof-0.7.37 → methodproof-0.8.0}/.github/workflows/ci.yml +6 -6
  2. {methodproof-0.7.37 → methodproof-0.8.0}/CHANGELOG.md +26 -0
  3. {methodproof-0.7.37 → methodproof-0.8.0}/PKG-INFO +2 -1
  4. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/base.py +18 -1
  5. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/watcher.py +101 -0
  6. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/cli.py +76 -8
  7. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/crypto.py +17 -0
  8. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/e2e.py +74 -30
  9. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/claude_code.py +2 -4
  10. methodproof-0.8.0/methodproof/hooks/gemini_hook.sh +123 -0
  11. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/install.py +5 -1
  12. methodproof-0.8.0/methodproof/migrate_db.py +161 -0
  13. methodproof-0.8.0/methodproof/repos.py +70 -0
  14. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/store.py +77 -6
  15. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/sync.py +10 -1
  16. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/viewer.py +1 -1
  17. {methodproof-0.7.37 → methodproof-0.8.0}/pyproject.toml +2 -2
  18. {methodproof-0.7.37 → methodproof-0.8.0}/tests/conftest.py +1 -0
  19. methodproof-0.8.0/tests/test_repos.py +73 -0
  20. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_security.py +102 -1
  21. {methodproof-0.7.37 → methodproof-0.8.0}/uv.lock +168 -1
  22. methodproof-0.7.37/methodproof/hooks/gemini_hook.sh +0 -58
  23. methodproof-0.7.37/methodproof/migrate_db.py +0 -41
  24. methodproof-0.7.37/methodproof/repos.py +0 -25
  25. {methodproof-0.7.37 → methodproof-0.8.0}/.gitignore +0 -0
  26. {methodproof-0.7.37 → methodproof-0.8.0}/LICENSE +0 -0
  27. {methodproof-0.7.37 → methodproof-0.8.0}/README.md +0 -0
  28. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/__init__.py +0 -0
  29. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/__main__.py +0 -0
  30. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/_daemon.py +0 -0
  31. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/__init__.py +0 -0
  32. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/music.py +0 -0
  33. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/agents/terminal.py +0 -0
  34. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/analysis.py +0 -0
  35. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/binding.py +0 -0
  36. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/bip39.py +0 -0
  37. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/bridge.py +0 -0
  38. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/config.py +0 -0
  39. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/graph.py +0 -0
  40. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hook.py +0 -0
  41. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/__init__.py +0 -0
  42. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/claude_code.sh +0 -0
  43. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/cline_hook.sh +0 -0
  44. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/codex_hook.sh +0 -0
  45. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/kiro_hook.sh +0 -0
  46. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/mcp_register.py +0 -0
  47. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/openclaw/HOOK.md +0 -0
  48. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/openclaw/handler.ts +0 -0
  49. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/openclaw_install.py +0 -0
  50. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/opencode_plugin.js +0 -0
  51. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/hooks/wrappers.py +0 -0
  52. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/integrity.py +0 -0
  53. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/kdf.py +0 -0
  54. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/keychain.py +0 -0
  55. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/live.py +0 -0
  56. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/lock.py +0 -0
  57. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/mcp.py +0 -0
  58. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/proxy.py +0 -0
  59. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/proxy_daemon.py +0 -0
  60. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/skills/methodproof/SKILL.md +0 -0
  61. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/__init__.py +0 -0
  62. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/consent.py +0 -0
  63. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/init.py +0 -0
  64. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/log.py +0 -0
  65. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/login_success.py +0 -0
  66. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/review.py +0 -0
  67. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/start.py +0 -0
  68. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/status.py +0 -0
  69. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/tui/theme.py +0 -0
  70. {methodproof-0.7.37 → methodproof-0.8.0}/methodproof/wordlist.py +0 -0
  71. {methodproof-0.7.37 → methodproof-0.8.0}/test_windows_compat.py +0 -0
  72. {methodproof-0.7.37 → methodproof-0.8.0}/tests/__init__.py +0 -0
  73. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_analysis.py +0 -0
  74. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_auth.py +0 -0
  75. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_config.py +0 -0
  76. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_helpers.py +0 -0
  77. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_session.py +0 -0
  78. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_share.py +0 -0
  79. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_start.py +0 -0
  80. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_cli_update.py +0 -0
  81. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_e2e_integration.py +0 -0
  82. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_graph.py +0 -0
  83. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_hooks.py +0 -0
  84. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_live.py +0 -0
  85. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_openclaw_hooks.py +0 -0
  86. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_profiles.py +0 -0
  87. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_store.py +0 -0
  88. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_sync.py +0 -0
  89. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_viewer.py +0 -0
  90. {methodproof-0.7.37 → methodproof-0.8.0}/tests/test_wrappers.py +0 -0
@@ -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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0] — 2026-04-15
4
+
5
+ ### Added
6
+ - **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.
7
+ - **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.
8
+ - **Daemon-liveness guard** — `DaemonActiveError` is raised if a recording daemon is still holding FDs on the plaintext DB. Callers must `mp stop` first.
9
+ - **`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.
10
+ - **`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.
11
+ - **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`).
12
+
13
+ ### Fixed
14
+ - **`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")`.
15
+
16
+ ### Why
17
+ 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.
18
+
19
+ ## [0.7.38] — 2026-04-12
20
+
21
+ ### Added
22
+ - **Structural hunk capture on `file_edit` and `git_commit`** — watcher parses `@@ -old,count +new,count @@` headers from `git diff --unified=0` and `git show --format= --unified=0 <sha>` and emits them as `hunks: [{old_start, old_count, new_start, new_count}, ...]` on `file_edit` metadata and `file_hunks: {path: [hunks, ...]}` on `git_commit` metadata. Pure range data — no code content, no identifiers. Stays inside the structural-capture boundary alongside `lines_added` / `lines_removed`.
23
+ - **Journal-mode hunk content** — when `code_capture` consent is on (Pro tier, journal mode), each hunk additionally carries a `lines: ["+foo", "-bar", ...]` field with the actual diff lines for that hunk, capped at 2000 lines per event. Gated by the same consent flag that gates the raw `diff` field.
24
+ - **`base.is_content_captured()`** — public helper in `methodproof/agents/base.py` exposing the `code_capture` capture-flag check so agents can decide what content to include without reaching into private module state.
25
+
26
+ ### Why
27
+ Enables the platform's post-processing pipeline to distinguish `SUPERSEDES` (later commit replaces 100% of an earlier commit's added lines on each shared path) from `EXTENSION_TO` (anything less — the default) without ever needing the raw diff content. See `methodproof` changelog v0.6.30.
28
+
3
29
  ## [0.7.37] — 2026-04-12
4
30
 
5
31
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.7.37
3
+ Version: 0.8.0
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
@@ -108,9 +108,19 @@ def init(session_id: str, live: bool = False, verbose: bool = False, streaming:
108
108
  _verbose = verbose
109
109
  _streaming = streaming
110
110
  _prev_hash = "genesis"
111
- from methodproof import config
111
+ from methodproof import config, store
112
112
  cfg = config.load()
113
113
  _e2e_key = _load_encryption_key(cfg)
114
+ # Daemon is a fresh process — wire the SQLCipher key (always master-derived
115
+ # db_key, never user E2E) into the store before any DB write.
116
+ if store._encrypted_flag_path().exists():
117
+ account_id = cfg.get("account_id", "")
118
+ if account_id and cfg.get("master_key_fingerprint"):
119
+ from methodproof.keychain import load_secret
120
+ from methodproof.kdf import derive_master, derive_db_key
121
+ entropy = load_secret(account_id)
122
+ if entropy:
123
+ store.set_db_key(derive_db_key(derive_master(entropy), account_id))
114
124
  _capture = cfg.get("capture", {})
115
125
  _journal_mode = cfg.get("journal_mode", False)
116
126
  _account_id = cfg.get("account_id", "")
@@ -125,6 +135,13 @@ def log(level: str, event: str, **kw: object) -> None:
125
135
  sys.stderr.write(json.dumps(entry, default=str) + "\n")
126
136
 
127
137
 
138
+ def is_content_captured() -> bool:
139
+ """True when code_capture consent is on (journal/Pro mode). Agents use this
140
+ to decide whether to include line-level diff content alongside structural
141
+ metadata."""
142
+ return bool(_capture.get("code_capture", False))
143
+
144
+
128
145
  def emit(event_type: str, metadata: dict[str, Any]) -> None:
129
146
  if not _initialized:
130
147
  log("warning", "emit.before_init", type=event_type)
@@ -46,6 +46,99 @@ IGNORE_PATTERNS = re.compile(
46
46
 
47
47
 
48
48
  _MAX_DIFF_BYTES = 50_000
49
+ _MAX_HUNK_LINES = 2_000
50
+
51
+ _HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
52
+ _DIFF_FILE_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)$")
53
+
54
+
55
+ def _parse_hunks(diff_text: str, include_lines: bool) -> list[dict[str, object]]:
56
+ """Parse unified diff text into structured hunks.
57
+
58
+ Each hunk: {old_start, old_count, new_start, new_count, [lines]}.
59
+ Lines are only included when `include_lines` is True (journal / code_capture).
60
+ """
61
+ hunks: list[dict[str, object]] = []
62
+ current: dict[str, object] | None = None
63
+ total_lines = 0
64
+ for line in diff_text.splitlines():
65
+ header = _HUNK_HEADER_RE.match(line)
66
+ if header:
67
+ if current is not None:
68
+ hunks.append(current)
69
+ current = {
70
+ "old_start": int(header.group(1)),
71
+ "old_count": int(header.group(2) or 1),
72
+ "new_start": int(header.group(3)),
73
+ "new_count": int(header.group(4) or 1),
74
+ }
75
+ if include_lines:
76
+ current["lines"] = []
77
+ continue
78
+ if current is None or not include_lines:
79
+ continue
80
+ if not (line.startswith("+") or line.startswith("-")):
81
+ continue
82
+ if line.startswith("+++") or line.startswith("---"):
83
+ continue
84
+ if total_lines >= _MAX_HUNK_LINES:
85
+ continue
86
+ current["lines"].append(line) # type: ignore[union-attr]
87
+ total_lines += 1
88
+ if current is not None:
89
+ hunks.append(current)
90
+ return hunks
91
+
92
+
93
+ def _parse_show_hunks(
94
+ show_text: str, include_lines: bool,
95
+ ) -> dict[str, list[dict[str, object]]]:
96
+ """Split `git show --unified=0` output by file and parse hunks per file."""
97
+ file_hunks: dict[str, list[dict[str, object]]] = {}
98
+ current_path: str | None = None
99
+ buffer: list[str] = []
100
+
101
+ def _flush() -> None:
102
+ if current_path and buffer:
103
+ file_hunks[current_path] = _parse_hunks("\n".join(buffer), include_lines)
104
+
105
+ for line in show_text.splitlines():
106
+ m = _DIFF_FILE_RE.match(line)
107
+ if m:
108
+ _flush()
109
+ current_path = m.group(2)
110
+ buffer = []
111
+ continue
112
+ if current_path is not None:
113
+ buffer.append(line)
114
+ _flush()
115
+ return file_hunks
116
+
117
+
118
+ def _git_diff_hunks(repo: str, path: str, include_lines: bool) -> list[dict[str, object]]:
119
+ """Structured hunks for a pending file_edit. Ranges always; line content when allowed."""
120
+ try:
121
+ result = subprocess.run(
122
+ ["git", "-C", repo, "diff", "--unified=0", "--", path],
123
+ capture_output=True, text=True, timeout=5,
124
+ )
125
+ return _parse_hunks(result.stdout[:_MAX_DIFF_BYTES], include_lines)
126
+ except Exception:
127
+ return []
128
+
129
+
130
+ def _git_show_file_hunks(
131
+ repo: str, sha: str, include_lines: bool,
132
+ ) -> dict[str, list[dict[str, object]]]:
133
+ """Per-file structured hunks for a commit."""
134
+ try:
135
+ result = subprocess.run(
136
+ ["git", "-C", repo, "show", "--format=", "--unified=0", sha],
137
+ capture_output=True, text=True, timeout=10,
138
+ )
139
+ return _parse_show_hunks(result.stdout[:_MAX_DIFF_BYTES * 4], include_lines)
140
+ except Exception:
141
+ return {}
49
142
 
50
143
 
51
144
  def _git_diff_stats(repo: str, path: str) -> tuple[int, int]:
@@ -125,10 +218,14 @@ class _Handler(FileSystemEventHandler):
125
218
 
126
219
  added, removed = _git_diff_stats(self._root, path)
127
220
  lang = Path(event.src_path).suffix.lstrip(".")
221
+ include_lines = base.is_content_captured()
222
+ hunks = _git_diff_hunks(self._root, path, include_lines)
128
223
  meta: dict[str, object] = {
129
224
  "path": path, "language": lang,
130
225
  "lines_added": added, "lines_removed": removed,
131
226
  }
227
+ if hunks:
228
+ meta["hunks"] = hunks
132
229
  diff = _git_diff_content(self._root, path)
133
230
  if diff:
134
231
  meta["diff"] = diff
@@ -192,6 +289,10 @@ def _log_commit(watch_dir: str, sha: str) -> None:
192
289
  meta["parent_hash"] = parent_hash
193
290
  if body and body != subject:
194
291
  meta["body"] = body[:2000]
292
+ include_lines = base.is_content_captured()
293
+ file_hunks = _git_show_file_hunks(watch_dir, sha, include_lines)
294
+ if file_hunks:
295
+ meta["file_hunks"] = file_hunks
195
296
  diff = _git_show_diff(watch_dir, sha)
196
297
  if diff:
197
298
  meta["diff"] = diff
@@ -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():
@@ -1844,7 +1910,7 @@ def cmd_push(args: argparse.Namespace) -> None:
1844
1910
  print(f"Found {len(unsynced)} unsynced sessions:\n")
1845
1911
  for s in unsynced:
1846
1912
  events = len(store.get_events(s["id"]))
1847
- date = s["created_at"][:10] if s.get("created_at") else "?"
1913
+ date = datetime.fromtimestamp(s["created_at"]).strftime("%Y-%m-%d") if s.get("created_at") else "?"
1848
1914
  print(f" {s['id'][:8]} {date} {events} events")
1849
1915
  print()
1850
1916
  answer = input(f"Push all {len(unsynced)}? [Y/n] ").strip().lower()
@@ -2342,6 +2408,7 @@ def main() -> None:
2342
2408
  e2e_sub.add_parser("recover", help="Recover key from passphrase")
2343
2409
  e2e_rel = e2e_sub.add_parser("release", help="Release a session from E2E encryption")
2344
2410
  e2e_rel.add_argument("session_id", help="Session ID to release")
2411
+ e2e_sub.add_parser("release-all", help="Release every synced session from E2E encryption")
2345
2412
  sub.add_parser("intro", help="Show the MethodProof intro")
2346
2413
  sub.add_parser("help", help="Show command reference")
2347
2414
  sub.add_parser("mcp-serve", help="Run MCP server (used by Claude Code)")
@@ -2381,5 +2448,6 @@ def main() -> None:
2381
2448
  _update_check()
2382
2449
 
2383
2450
  if args.cmd not in ("help", "update"):
2451
+ _wire_db_encryption(config.load())
2384
2452
  store.init_db()
2385
2453
  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
@@ -223,64 +223,106 @@ def _cmd_recover(cfg: dict) -> None:
223
223
  print(f"Key recovered and stored in OS keychain (fingerprint: {fp}).")
224
224
 
225
225
 
226
- def _cmd_release(cfg: dict, session_id: str) -> None:
227
- """Release a session from E2E encryption."""
228
- from methodproof import keychain
226
+ def _release_session(session_id: str, remote_id: str, e2e_key: bytes,
227
+ api_url: str, token: str) -> int:
228
+ """Decrypt and upload one session. Returns decrypted event count (0 = nothing to do)."""
229
229
  from methodproof.crypto import decrypt_field, SENSITIVE_FIELDS
230
230
  from methodproof.sync import _request
231
231
 
232
+ decrypted_events = []
233
+ for ev in store.get_events(session_id):
234
+ meta = json.loads(ev.get("metadata", "{}"))
235
+ changed = False
236
+ for field in SENSITIVE_FIELDS:
237
+ val = meta.get(field, "")
238
+ if isinstance(val, str) and val.startswith("e2e:v1:"):
239
+ meta[field] = decrypt_field(val, e2e_key)
240
+ changed = True
241
+ if changed:
242
+ decrypted_events.append({"event_id": ev["id"], "metadata": meta})
243
+
244
+ if not decrypted_events:
245
+ return 0
246
+
247
+ _request("POST", f"/personal/sessions/{remote_id}/release-e2e", api_url, token,
248
+ {"events": decrypted_events})
249
+ return len(decrypted_events)
250
+
251
+
252
+ def _load_release_context(cfg: dict) -> tuple[bytes, str, str]:
253
+ """Return (e2e_key, api_url, token) or exit with a clear error."""
254
+ from methodproof import keychain
255
+
232
256
  token = cfg.get("token", "")
233
257
  api_url = cfg.get("api_url", "")
234
258
  account_id = cfg.get("account_id", "")
235
259
  if not token:
236
260
  print("Release requires login. Run `methodproof login` first.")
237
261
  sys.exit(1)
238
-
239
262
  e2e_key = keychain.load_secret(f"e2e:{account_id}")
240
263
  if not e2e_key:
241
264
  print("E2E key not found in keychain. Run `mp e2e recover` first.")
242
265
  sys.exit(1)
266
+ return e2e_key, api_url, token
243
267
 
244
- events = store.get_events(session_id)
245
- if not events:
246
- print(f"No events found for session {session_id}.")
247
- sys.exit(1)
248
268
 
249
- decrypted_events = []
250
- for ev in events:
251
- meta = json.loads(ev.get("metadata", "{}"))
252
- has_encrypted = False
253
- for field in SENSITIVE_FIELDS:
254
- val = meta.get(field, "")
255
- if isinstance(val, str) and val.startswith("e2e:v1:"):
256
- meta[field] = decrypt_field(val, e2e_key)
257
- has_encrypted = True
258
- if has_encrypted:
259
- decrypted_events.append({"event_id": ev["id"], "metadata": meta})
269
+ def _cmd_release(cfg: dict, session_id: str) -> None:
270
+ """Release a session from E2E encryption."""
271
+ e2e_key, api_url, token = _load_release_context(cfg)
260
272
 
261
- if not decrypted_events:
262
- print("No encrypted fields found in this session.")
263
- return
273
+ if not store.get_events(session_id):
274
+ print(f"No events found for session {session_id}.")
275
+ sys.exit(1)
264
276
 
265
- # Resolve remote_id
266
- sessions = store.list_sessions()
267
277
  remote_id = None
268
- for s in sessions:
278
+ for s in store.list_sessions():
269
279
  if s["id"] == session_id or (s.get("remote_id") and s["id"].startswith(session_id)):
270
280
  remote_id = s.get("remote_id")
271
281
  session_id = s["id"]
272
282
  break
273
-
274
283
  if not remote_id:
275
284
  print("Session not synced to platform. Push first with `mp push`.")
276
285
  sys.exit(1)
277
286
 
278
- _request("POST", f"/personal/sessions/{remote_id}/release-e2e", api_url, token,
279
- {"events": decrypted_events})
280
- print(f"Session released ({len(decrypted_events)} events decrypted).")
287
+ count = _release_session(session_id, remote_id, e2e_key, api_url, token)
288
+ if not count:
289
+ print("No encrypted fields found in this session.")
290
+ return
291
+ print(f"Session released ({count} events decrypted).")
281
292
  print("Narration will be generated shortly.")
282
293
 
283
294
 
295
+ def _cmd_release_all(cfg: dict) -> None:
296
+ """Release every synced session that still has E2E-encrypted fields."""
297
+ e2e_key, api_url, token = _load_release_context(cfg)
298
+
299
+ sessions = [s for s in store.list_sessions() if s.get("remote_id")]
300
+ if not sessions:
301
+ print("No synced sessions found. Push first with `mp push`.")
302
+ return
303
+
304
+ released = skipped = failed = total_events = 0
305
+ for s in sessions:
306
+ sid, rid = s["id"], s["remote_id"]
307
+ try:
308
+ count = _release_session(sid, rid, e2e_key, api_url, token)
309
+ except Exception as exc:
310
+ failed += 1
311
+ print(f" {sid[:8]} FAILED ({exc})")
312
+ continue
313
+ if count:
314
+ released += 1
315
+ total_events += count
316
+ print(f" {sid[:8]} released ({count} events)")
317
+ else:
318
+ skipped += 1
319
+
320
+ print(f"\nReleased {released} sessions ({total_events} events). "
321
+ f"Skipped {skipped}. Failed {failed}.")
322
+ if released:
323
+ print("Narration will be generated shortly.")
324
+
325
+
284
326
  def cmd_e2e(args: argparse.Namespace) -> None:
285
327
  """E2E encryption mode — personal key management."""
286
328
  subcmd = getattr(args, "e2e_cmd", None)
@@ -300,5 +342,7 @@ def cmd_e2e(args: argparse.Namespace) -> None:
300
342
  print("Usage: methodproof e2e release <session-id>")
301
343
  sys.exit(1)
302
344
  _cmd_release(cfg, session_id)
345
+ elif subcmd == "release-all":
346
+ _cmd_release_all(cfg)
303
347
  else:
304
- print("Usage: methodproof e2e [on|off|status|recover|release <session-id>]")
348
+ print("Usage: methodproof e2e [on|off|status|recover|release <session-id>|release-all]")
@@ -150,11 +150,9 @@ def main() -> None:
150
150
  return
151
151
 
152
152
  event = data.get("hook_event_name", "unknown")
153
- etype = _TYPE_MAP.get(event)
154
- if not etype:
155
- return # Unmapped hook event — drop rather than send invalid type
153
+ etype = _TYPE_MAP.get(event, "claude_code_event")
156
154
  extractor = _META_EXTRACTORS.get(event)
157
- meta = extractor(data) if extractor else {"tool": _TOOL}
155
+ meta = extractor(data) if extractor else {"tool": _TOOL, "event": event}
158
156
  ts = time.time()
159
157
 
160
158
  payload = json.dumps({"events": [{"type": etype, "timestamp": ts, "metadata": meta}]}).encode()