methodproof 0.6.0__tar.gz → 0.7.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 (65) hide show
  1. {methodproof-0.6.0 → methodproof-0.7.1}/CHANGELOG.md +22 -0
  2. {methodproof-0.6.0 → methodproof-0.7.1}/PKG-INFO +1 -1
  3. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/__init__.py +1 -1
  4. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/_daemon.py +16 -2
  5. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/agents/base.py +63 -4
  6. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/agents/watcher.py +29 -1
  7. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/cli.py +158 -25
  8. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/config.py +5 -0
  9. methodproof-0.7.1/methodproof/e2e.py +304 -0
  10. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/claude_code.py +47 -1
  11. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/claude_code.sh +52 -2
  12. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/install.py +14 -0
  13. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/proxy_daemon.py +17 -2
  14. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/store.py +8 -2
  15. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/sync.py +27 -0
  16. {methodproof-0.6.0 → methodproof-0.7.1}/pyproject.toml +1 -1
  17. {methodproof-0.6.0 → methodproof-0.7.1}/uv.lock +1 -1
  18. {methodproof-0.6.0 → methodproof-0.7.1}/.github/workflows/ci.yml +0 -0
  19. {methodproof-0.6.0 → methodproof-0.7.1}/.gitignore +0 -0
  20. {methodproof-0.6.0 → methodproof-0.7.1}/LICENSE +0 -0
  21. {methodproof-0.6.0 → methodproof-0.7.1}/README.md +0 -0
  22. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/__main__.py +0 -0
  23. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/agents/__init__.py +0 -0
  24. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/agents/music.py +0 -0
  25. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/agents/terminal.py +0 -0
  26. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/analysis.py +0 -0
  27. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/binding.py +0 -0
  28. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/bip39.py +0 -0
  29. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/bridge.py +0 -0
  30. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/crypto.py +0 -0
  31. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/graph.py +0 -0
  32. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hook.py +0 -0
  33. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/__init__.py +0 -0
  34. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/cline_hook.sh +0 -0
  35. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/codex_hook.sh +0 -0
  36. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/gemini_hook.sh +0 -0
  37. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/kiro_hook.sh +0 -0
  38. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/mcp_register.py +0 -0
  39. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/openclaw/HOOK.md +0 -0
  40. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/openclaw/handler.ts +0 -0
  41. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/openclaw_install.py +0 -0
  42. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/opencode_plugin.js +0 -0
  43. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/hooks/wrappers.py +0 -0
  44. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/integrity.py +0 -0
  45. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/kdf.py +0 -0
  46. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/keychain.py +0 -0
  47. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/live.py +0 -0
  48. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/lock.py +0 -0
  49. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/mcp.py +0 -0
  50. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/migrate_db.py +0 -0
  51. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/proxy.py +0 -0
  52. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/repos.py +0 -0
  53. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/skills/methodproof/SKILL.md +0 -0
  54. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/viewer.py +0 -0
  55. {methodproof-0.6.0 → methodproof-0.7.1}/methodproof/wordlist.py +0 -0
  56. {methodproof-0.6.0 → methodproof-0.7.1}/test_windows_compat.py +0 -0
  57. {methodproof-0.6.0 → methodproof-0.7.1}/tests/__init__.py +0 -0
  58. {methodproof-0.6.0 → methodproof-0.7.1}/tests/test_analysis.py +0 -0
  59. {methodproof-0.6.0 → methodproof-0.7.1}/tests/test_graph.py +0 -0
  60. {methodproof-0.6.0 → methodproof-0.7.1}/tests/test_hooks.py +0 -0
  61. {methodproof-0.6.0 → methodproof-0.7.1}/tests/test_live.py +0 -0
  62. {methodproof-0.6.0 → methodproof-0.7.1}/tests/test_openclaw_hooks.py +0 -0
  63. {methodproof-0.6.0 → methodproof-0.7.1}/tests/test_security.py +0 -0
  64. {methodproof-0.6.0 → methodproof-0.7.1}/tests/test_store.py +0 -0
  65. {methodproof-0.6.0 → methodproof-0.7.1}/tests/test_wrappers.py +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1] — 2026-04-07
4
+
5
+ ### Fixed
6
+ - **7 GB database bloat** — `artifacts` table lacked a UNIQUE constraint on `path`, causing `_ensure_artifact` to insert a duplicate row per `file_edit`. The `_link_action_artifacts` JOIN then produced a cartesian explosion (39.9M rows from 16K events). Added UNIQUE constraint + migration that deduplicates existing data on upgrade.
7
+ - **Stale PID after reboot** — `_is_daemon_alive()` only checked `os.kill(pid, 0)`, which succeeds if the OS reused the PID for an unrelated process. Now verifies the process name contains "methodproof" via `ps`. Prevents killing random processes and unblocks `mp stop`/`mp start` after a system restart.
8
+ - **SQLite hang on locked database** — `sqlite3.connect()` had no timeout, so a crashed daemon holding a WAL lock caused `mp stop` to hang forever. Added `timeout=10`.
9
+
10
+ ### Added
11
+ - **Research consent sync** — `mp init`, `mp consent`, `mp login`, and `mp push` now sync research opt-in state between CLI and platform. Local changes are flagged with `_pending_research_sync` and pushed on next authenticated call; canonical state is always pulled from the platform.
12
+
13
+ ## [0.7.0] — 2026-04-07
14
+
15
+ ### Fixed
16
+ - **Runaway event logging** — watcher ignored only 7 patterns, tracking 35K+ files including `.venv/` (20K), `dist/build/` (23K). Expanded to 30+ ignore patterns covering Python, JS/TS, Rust, Go, Java, Ruby, PHP, .NET, Swift, and build artifacts. **138K → 1,442 files (99% reduction)**
17
+ - **Claude Code hook errors** — hook script pointed to deleted pyenv site-packages path; added pidfile guard (`exit 0` when no session active) to prevent bridge connection failures
18
+
19
+ ### Added
20
+ - **Configurable local AI ports** — `mp init` now asks about local LLM servers (Ollama, LM Studio, vLLM, etc.) and adds user-specified ports to the proxy allowlist. Previously only 3 hardcoded ports (11434, 18789, 1234) were captured; custom ports were invisible to the proxy decoder.
21
+ - `mp start --streaming` — blocking foreground mode, streams every captured event to stdout in real-time with human-readable formatting
22
+ - `mp start --verbose` / `-v` — structured debug logging at each step (config, auth, session, anchor, daemon spawn); daemon log includes agent init and buffer/flush stats
23
+ - Step-by-step progress output on default `mp start` (`→ Loading config`, `→ Checking hooks`, etc.) so failures are immediately locatable
24
+
3
25
  ## [0.5.1] — 2026-04-06
4
26
 
5
27
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -1,3 +1,3 @@
1
1
  """MethodProof — see how you code."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.1"
@@ -18,6 +18,7 @@ PIDFILE = config.DIR / "methodproof.pid"
18
18
  def main() -> None:
19
19
  sid = sys.argv[1]
20
20
  watch_dir = sys.argv[2]
21
+ verbose = "--verbose" in sys.argv
21
22
 
22
23
  cfg = config.load()
23
24
  capture = cfg.get("capture", {})
@@ -28,10 +29,15 @@ def main() -> None:
28
29
  del cfg["_live_url"]
29
30
  config.save(cfg)
30
31
 
31
- base.init(sid, live=bool(live_url))
32
+ base.init(sid, live=bool(live_url), verbose=verbose)
33
+
34
+ if verbose:
35
+ active = [k for k, v in capture.items() if v]
36
+ base.log("info", "daemon.config", capture=active, live=bool(live_url),
37
+ journal=cfg.get("journal_mode", False))
32
38
 
33
- # Environment profile (was done pre-fork in old code, now done in daemon)
34
39
  if capture.get("environment_analysis", True):
40
+ base.log("info", "daemon.agent.environment_scan") if verbose else None
35
41
  try:
36
42
  from methodproof.analysis import scan_environment
37
43
  env_profile = scan_environment(watch_dir)
@@ -52,12 +58,16 @@ def main() -> None:
52
58
  threads.append(threading.Thread(
53
59
  target=watcher.start, args=(watch_dir, stop_event), daemon=True,
54
60
  ))
61
+ if verbose:
62
+ base.log("info", "daemon.agent.watcher", dir=watch_dir)
55
63
 
56
64
  if capture.get("terminal_commands", True) or capture.get("test_results", True):
57
65
  from methodproof.agents import terminal
58
66
  threads.append(threading.Thread(
59
67
  target=terminal.start, args=(stop_event,), daemon=True,
60
68
  ))
69
+ if verbose:
70
+ base.log("info", "daemon.agent.terminal", log=str(config.CMD_LOG))
61
71
 
62
72
  if capture.get("browser", True):
63
73
  from methodproof import bridge
@@ -68,12 +78,16 @@ def main() -> None:
68
78
  cfg.get("journal_mode", False)),
69
79
  daemon=True,
70
80
  ))
81
+ if verbose:
82
+ base.log("info", "daemon.agent.bridge", port=9877)
71
83
 
72
84
  if capture.get("music", True):
73
85
  from methodproof.agents import music
74
86
  threads.append(threading.Thread(
75
87
  target=music.start, args=(stop_event,), daemon=True,
76
88
  ))
89
+ if verbose:
90
+ base.log("info", "daemon.agent.music")
77
91
 
78
92
  def _shutdown(sig_num: int, frame: object) -> None:
79
93
  stop_event.set()
@@ -21,6 +21,8 @@ _MAX_RETRIES = 3
21
21
  _prev_hash = "genesis"
22
22
  _account_id = ""
23
23
  _journal_mode = False
24
+ _verbose = False
25
+ _streaming = False
24
26
 
25
27
  # Maps event types to the capture category that gates them
26
28
  _EVENT_GATES: dict[str, str] = {
@@ -78,8 +80,15 @@ _FIELD_GATES: dict[str, list[tuple[str, str]]] = {
78
80
 
79
81
 
80
82
  def _load_encryption_key(cfg: dict) -> bytes | None:
81
- """Load db_key from keychain (preferred) or legacy e2e_key config."""
83
+ """Load encryption key: individual E2E > db_key from keychain > legacy e2e_key."""
82
84
  account_id = cfg.get("account_id", "")
85
+ # Individual E2E key — highest priority when session is E2E
86
+ if account_id and cfg.get("e2e_fingerprint") and cfg.get("_session_e2e"):
87
+ from methodproof.keychain import load_secret
88
+ e2e_key = load_secret(f"e2e:{account_id}")
89
+ if e2e_key:
90
+ return e2e_key
91
+ # Local db_key (for SQLite encryption)
83
92
  if account_id and cfg.get("master_key_fingerprint"):
84
93
  from methodproof.keychain import load_secret
85
94
  from methodproof.kdf import derive_master, derive_db_key
@@ -91,11 +100,13 @@ def _load_encryption_key(cfg: dict) -> bytes | None:
91
100
  return bytes.fromhex(raw) if raw else None
92
101
 
93
102
 
94
- def init(session_id: str, live: bool = False) -> None:
95
- global _session_id, _initialized, _e2e_key, _capture, _live_mode, _prev_hash, _journal_mode, _account_id
103
+ def init(session_id: str, live: bool = False, verbose: bool = False, streaming: bool = False) -> None:
104
+ global _session_id, _initialized, _e2e_key, _capture, _live_mode, _prev_hash, _journal_mode, _account_id, _verbose, _streaming
96
105
  _session_id = session_id
97
106
  _initialized = True
98
107
  _live_mode = live
108
+ _verbose = verbose
109
+ _streaming = streaming
99
110
  _prev_hash = "genesis"
100
111
  from methodproof import config
101
112
  cfg = config.load()
@@ -103,6 +114,10 @@ def init(session_id: str, live: bool = False) -> None:
103
114
  _capture = cfg.get("capture", {})
104
115
  _journal_mode = cfg.get("journal_mode", False)
105
116
  _account_id = cfg.get("account_id", "")
117
+ if _verbose or _streaming:
118
+ active = [k for k, v in _capture.items() if v]
119
+ log("info", "base.init", encryption=bool(_e2e_key), journal=_journal_mode,
120
+ live=_live_mode, capture=active)
106
121
 
107
122
 
108
123
  def log(level: str, event: str, **kw: object) -> None:
@@ -153,6 +168,10 @@ def emit(event_type: str, metadata: dict[str, Any]) -> None:
153
168
  _buffer.append(entry)
154
169
  if len(_buffer) >= _FLUSH_SIZE:
155
170
  _flush_locked()
171
+ if _verbose:
172
+ log("debug", "emit.buffered", type=event_type, buffer=len(_buffer))
173
+ if _streaming:
174
+ _stream_event(entry)
156
175
  if _live_mode:
157
176
  from methodproof import live as live_mod
158
177
  live_mod.send(entry)
@@ -168,15 +187,55 @@ def _flush_locked() -> None:
168
187
  return
169
188
  batch = list(_buffer)
170
189
  hashes = [(e["id"], e.pop("_chain_hash")) for e in batch if "_chain_hash" in e]
190
+ if _verbose or _streaming:
191
+ types = [e["type"] for e in batch]
192
+ log("info", "flush.start", count=len(batch), types=types)
171
193
  for attempt in range(_MAX_RETRIES):
172
194
  try:
173
195
  store.insert_events(_session_id, batch)
174
196
  if hashes:
175
197
  store.insert_event_hashes(hashes)
198
+ if _verbose or _streaming:
199
+ log("info", "flush.ok", count=len(batch))
176
200
  _buffer.clear()
177
201
  return
178
202
  except Exception as exc:
179
203
  log("warning", "flush.retry", attempt=attempt + 1, error=str(exc))
180
204
  time.sleep(0.1 * (attempt + 1))
181
- # Final attempt failed — keep events in buffer for next flush cycle
182
205
  log("error", "flush.failed", count=len(batch), retries=_MAX_RETRIES)
206
+
207
+
208
+ def _stream_event(entry: dict[str, Any]) -> None:
209
+ """Print a human-readable event line to stdout for --streaming mode."""
210
+ ts = time.strftime("%H:%M:%S", time.localtime(entry["timestamp"]))
211
+ etype = entry["type"]
212
+ meta = entry.get("metadata", {})
213
+ # Build a compact summary per event type
214
+ detail = ""
215
+ if etype == "file_edit":
216
+ detail = f'{meta.get("path", "?")} +{meta.get("lines_added", 0)}-{meta.get("lines_removed", 0)}'
217
+ elif etype == "file_create":
218
+ detail = f'{meta.get("path", "?")} ({meta.get("size", 0)}B)'
219
+ elif etype == "file_delete":
220
+ detail = meta.get("path", "?")
221
+ elif etype == "terminal_cmd":
222
+ detail = f'{meta.get("command", "?")[:60]} → exit {meta.get("exit_code", "?")}'
223
+ elif etype == "test_run":
224
+ detail = f'{meta.get("framework", "?")} {meta.get("passed", 0)}✓ {meta.get("failed", 0)}✗'
225
+ elif etype == "git_commit":
226
+ detail = f'{meta.get("hash", "?")} {meta.get("message", "")[:50]}'
227
+ elif etype in ("llm_prompt", "agent_prompt", "user_prompt"):
228
+ detail = f'len={meta.get("prompt_length", meta.get("message_length", "?"))}'
229
+ elif etype in ("llm_completion", "agent_completion"):
230
+ detail = f'len={meta.get("response_length", "?")}'
231
+ elif etype == "music_playing":
232
+ detail = f'{meta.get("artist", "?")} — {meta.get("track", "?")}'
233
+ elif etype.startswith("browser_"):
234
+ detail = meta.get("url", meta.get("query", ""))[:60]
235
+ elif etype == "environment_profile":
236
+ detail = f'{meta.get("tool_count", "?")} tools'
237
+ else:
238
+ keys = list(meta.keys())[:4]
239
+ detail = ", ".join(f"{k}={meta[k]}" for k in keys) if keys else ""
240
+ sys.stdout.write(f" [{ts}] {etype:30s} {detail}\n")
241
+ sys.stdout.flush()
@@ -13,7 +13,35 @@ from watchdog.observers import Observer
13
13
  from methodproof.agents import base
14
14
 
15
15
  IGNORE_PATTERNS = re.compile(
16
- r"(__pycache__|\.pyc|\.git/|node_modules|\.DS_Store|\.swp|~$)"
16
+ # Version control
17
+ r"(\.git/|\.hg/|\.svn/"
18
+ # OS / editor artifacts
19
+ r"|\.DS_Store|Thumbs\.db|\.swp|\.swo|~$|\.idea/|\.vscode/"
20
+ # Python
21
+ r"|__pycache__|\.pyc|\.pyo|\.egg-info/|\.eggs/|\.tox/"
22
+ r"|\.venv/|venv/|\.env/|env/|\.mypy_cache/|\.pytest_cache/|\.ruff_cache/"
23
+ r"|\.coverage|htmlcov/|\.nox/"
24
+ # JavaScript / TypeScript
25
+ r"|node_modules/|\.next/|\.nuxt/|\.expo/|\.turbo/|\.parcel-cache/"
26
+ r"|\.svelte-kit/|\.angular/|\.cache/"
27
+ # Rust
28
+ r"|/target/|\.cargo/"
29
+ # Go
30
+ r"|vendor/"
31
+ # Java / Kotlin / JVM
32
+ r"|\.gradle/|\.m2/|/out/"
33
+ # Ruby
34
+ r"|\.bundle/|\.gem/"
35
+ # PHP
36
+ r"|/vendor/|\.phpunit/"
37
+ # .NET / C#
38
+ r"|/bin/|/obj/|\.nuget/"
39
+ # Swift / Xcode
40
+ r"|\.build/|DerivedData/|Pods/"
41
+ # Build output / artifacts
42
+ r"|dist/|build/|\.output/"
43
+ # Logs and locks
44
+ r"|\.lock$|\.log$)"
17
45
  )
18
46
 
19
47
 
@@ -337,6 +337,11 @@ def cmd_init(args: argparse.Namespace) -> None:
337
337
  if not cfg.get("consent_acknowledged"):
338
338
  cfg = _run_consent(cfg)
339
339
  config.save(cfg)
340
+ if cfg.get("token"):
341
+ cfg["_pending_research_sync"] = True
342
+ config.save(cfg)
343
+ from methodproof.sync import sync_research_consent
344
+ sync_research_consent(cfg["token"], cfg["api_url"])
340
345
  print()
341
346
 
342
347
  capture = cfg.get("capture", {})
@@ -408,6 +413,23 @@ def cmd_init(args: argparse.Namespace) -> None:
408
413
  else:
409
414
  print("AI hooks: skipped (ai_prompts and ai_responses disabled)")
410
415
 
416
+ # Local AI ports — capture traffic from local LLM servers
417
+ if ai_enabled and not cfg.get("local_ai_ports_offered"):
418
+ answer = input("Run any local AI models (Ollama, LM Studio, vLLM, etc.)? [y/N]: ").strip().lower()
419
+ cfg["local_ai_ports_offered"] = True
420
+ if answer == "y":
421
+ raw = input("Enter ports (comma-separated, e.g. 8080,5000,7860): ").strip()
422
+ ports = [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
423
+ cfg["local_ai_ports"] = ports
424
+ if ports:
425
+ print(f"Local AI ports: {', '.join(str(p) for p in ports)} (proxy will decode these)")
426
+ else:
427
+ print("Local AI ports: none added")
428
+ else:
429
+ cfg["local_ai_ports"] = []
430
+ print("Local AI ports: skipped (built-in: Ollama 11434, Jan 1234)")
431
+ config.save(cfg)
432
+
411
433
  # Signing keypair for attestation
412
434
  from methodproof.integrity import has_keypair
413
435
  if not has_keypair():
@@ -661,6 +683,11 @@ def cmd_consent(args: argparse.Namespace) -> None:
661
683
  print(f"\n{_banner()}\n")
662
684
  cfg = _run_consent_detailed(cfg)
663
685
  config.save(cfg)
686
+ if cfg.get("token"):
687
+ cfg["_pending_research_sync"] = True
688
+ config.save(cfg)
689
+ from methodproof.sync import sync_research_consent
690
+ sync_research_consent(cfg["token"], cfg["api_url"])
664
691
  print(f"\n{_banner()} settings saved.\n")
665
692
  _print_commands()
666
693
 
@@ -834,39 +861,71 @@ def _recover_master_key(cfg: dict, account_id: str) -> None:
834
861
 
835
862
 
836
863
  def _is_daemon_alive() -> bool:
837
- """Check if the recording daemon is still running."""
864
+ """Check if the recording daemon is still running (not a reused PID)."""
838
865
  if not PIDFILE.exists():
839
866
  return False
840
867
  try:
841
868
  pid = int(PIDFILE.read_text().strip())
842
- os.kill(pid, 0) # signal 0 = check existence without killing
843
- return True
869
+ os.kill(pid, 0)
844
870
  except (ProcessLookupError, ValueError, OSError):
845
871
  return False
872
+ # Verify the PID is actually a methodproof process (PIDs get reused after reboot)
873
+ try:
874
+ import subprocess
875
+ out = subprocess.check_output(["ps", "-p", str(pid), "-o", "args="], text=True).strip()
876
+ return "methodproof" in out
877
+ except Exception:
878
+ return False
879
+
880
+
881
+ def _log_step(msg: str, verbose: bool = False) -> None:
882
+ """Print a step-by-step progress line. Always shown."""
883
+ print(f" → {msg}")
884
+
885
+
886
+ def _log_debug(msg: str, **kw: object) -> None:
887
+ """Print structured debug line (--verbose only). Writes to stderr."""
888
+ import json as _json
889
+ entry = {"ts": time.time(), "level": "debug", "event": msg, **kw}
890
+ sys.stderr.write(_json.dumps(entry, default=str) + "\n")
846
891
 
847
892
 
848
893
  def cmd_start(args: argparse.Namespace) -> None:
894
+ verbose = getattr(args, "verbose", False)
895
+ streaming = getattr(args, "streaming", False)
896
+
897
+ _log_step("Loading config")
849
898
  cfg = config.load()
899
+ if verbose:
900
+ _log_debug("config.loaded", api_url=cfg.get("api_url"), account_id=cfg.get("account_id", "")[:8],
901
+ active_session=cfg.get("active_session"), journal=cfg.get("journal_mode"))
902
+
850
903
  if cfg.get("auto_update"):
904
+ _log_step("Checking for updates")
851
905
  _auto_update()
906
+
852
907
  if cfg.get("active_session"):
853
908
  if _is_daemon_alive():
854
909
  print(f"Session active: {cfg['active_session'][:8]}")
855
910
  print("Run `methodproof stop` first.")
856
911
  sys.exit(1)
857
- # Daemon is dead — clean up the stale session
858
912
  stale_sid = cfg["active_session"]
913
+ _log_step(f"Cleaning stale session {stale_sid[:8]}")
859
914
  store.complete_session(stale_sid)
860
915
  graph.build(stale_sid)
861
916
  PIDFILE.unlink(missing_ok=True)
862
917
  cfg["active_session"] = None
863
918
  config.save(cfg)
864
- print(f"Cleaned up stale session {stale_sid[:8]} (daemon was not running).")
919
+
920
+ _log_step("Checking hooks")
865
921
  if not hook.is_installed():
866
- print("Run `methodproof init` first.")
922
+ print("ERROR: Run `methodproof init` first.")
867
923
  sys.exit(1)
868
924
 
925
+ _log_step("Authenticating")
869
926
  account_id = _require_auth(cfg)
927
+ if verbose:
928
+ _log_debug("auth.ok", account_id=account_id[:8] if account_id else "none")
870
929
 
871
930
  # Check for new consent categories before recording
872
931
  capture = cfg.get("capture", {})
@@ -878,6 +937,7 @@ def cmd_start(args: argparse.Namespace) -> None:
878
937
  config.save(cfg)
879
938
  print()
880
939
 
940
+ _log_step("Creating session")
881
941
  sid = uuid.uuid4().hex
882
942
  watch_dir = os.path.abspath(args.dir or ".")
883
943
  repo_url = args.repo or repos.detect_repo(watch_dir)
@@ -885,7 +945,6 @@ def cmd_start(args: argparse.Namespace) -> None:
885
945
  visibility = "public" if args.public else "private"
886
946
  from methodproof.binding import compute_binding, compute_device_id
887
947
  device_id = compute_device_id()
888
- # Compute session binding if master key is available
889
948
  binding = ""
890
949
  if cfg.get("master_key_fingerprint") and account_id:
891
950
  from methodproof.keychain import load_secret
@@ -895,38 +954,49 @@ def cmd_start(args: argparse.Namespace) -> None:
895
954
  master = derive_master(entropy)
896
955
  bind_key = derive_bind_key(master, account_id)
897
956
  binding = compute_binding(bind_key, sid, account_id, device_id, time.time())
957
+ if verbose:
958
+ _log_debug("binding.computed", device_id=device_id[:8])
898
959
  store.create_session(sid, watch_dir, repo_url, json.dumps(tags), visibility,
899
960
  account_id, binding, device_id)
900
961
  cfg["active_session"] = sid
901
962
  config.save(cfg)
902
963
  PIDFILE.write_text(str(os.getpid()))
964
+ if verbose:
965
+ _log_debug("session.created", sid=sid[:8], watch_dir=watch_dir, visibility=visibility,
966
+ device_id=device_id[:8], bound=bool(binding))
903
967
 
904
- # Temporal anchor — server-signed timestamp (best-effort, skip if offline)
968
+ # Temporal anchor
905
969
  if cfg.get("token"):
970
+ _log_step("Requesting temporal anchor")
906
971
  try:
907
972
  from methodproof.sync import _request
908
973
  anchor = _request("POST", f"/sessions/{sid}/anchor", cfg["api_url"], cfg["token"])
909
974
  store.update_anchor(sid, anchor["anchor_ts"], anchor["signature"])
975
+ if verbose:
976
+ _log_debug("anchor.ok", anchor_ts=anchor["anchor_ts"])
910
977
  except Exception as exc:
911
- print(f"Anchor: skipped ({exc})")
978
+ _log_step(f"Anchor: skipped ({exc})")
912
979
 
913
980
  from methodproof.agents import base
914
- live_ok = False
915
981
  capture = cfg.get("capture", {})
916
982
 
983
+ # Live streaming
917
984
  live_url = ""
918
985
  want_live = args.live or getattr(args, "live_public", False)
919
986
  live_visibility = "public" if getattr(args, "live_public", False) else "private"
920
987
  if want_live:
988
+ _log_step("Connecting live stream")
921
989
  if not cfg.get("token"):
922
990
  print("Live mode requires login. Run `methodproof login` first.")
923
991
  sys.exit(1)
924
- # Create remote session first
925
992
  from methodproof.sync import _request
926
- result = _request("POST", "/personal/sessions", cfg["api_url"], cfg["token"])
993
+ session_body: dict = {}
994
+ if cfg.get("e2e_fingerprint") and (getattr(args, "e2e", False) or cfg.get("e2e_mode")):
995
+ session_body["e2e_key_fingerprint"] = cfg["e2e_fingerprint"]
996
+ result = _request("POST", "/personal/sessions", cfg["api_url"], cfg["token"],
997
+ session_body or None)
927
998
  remote_id = result["session_id"]
928
999
  store.mark_synced(sid, remote_id)
929
- # Connect live WebSocket
930
1000
  from methodproof import live as live_mod
931
1001
  live_url = live_mod.start(cfg["api_url"], cfg["token"], remote_id, capture, live_visibility) or ""
932
1002
  if not live_url:
@@ -939,7 +1009,7 @@ def cmd_start(args: argparse.Namespace) -> None:
939
1009
  print(f"Live (public): {live_url}")
940
1010
  print(" Anyone with this link can watch your session build in real time.")
941
1011
 
942
- # Journal mode — per-session override or persistent config
1012
+ # Journal mode
943
1013
  if getattr(args, "journal", False):
944
1014
  cfg["journal_mode"] = True
945
1015
  credits = cfg.get("journal_credits", 0)
@@ -950,6 +1020,18 @@ def cmd_start(args: argparse.Namespace) -> None:
950
1020
  print("Journal mode ON for this session (full content capture).")
951
1021
  config.save(cfg)
952
1022
 
1023
+ # E2E mode
1024
+ want_e2e = getattr(args, "e2e", False) or (cfg.get("e2e_mode") and not getattr(args, "no_e2e", False))
1025
+ if want_e2e:
1026
+ fp = cfg.get("e2e_fingerprint")
1027
+ if not fp:
1028
+ print("E2E requires key setup. Run `methodproof e2e on` first.")
1029
+ sys.exit(1)
1030
+ cfg["_session_e2e"] = True
1031
+ config.save(cfg)
1032
+ print("E2E mode ON for this session (content encrypted with your key).")
1033
+ print(" Narration unavailable. Release later with: mp e2e release <session-id>")
1034
+
953
1035
  # Save live_url for daemon subprocess to pick up
954
1036
  if live_url:
955
1037
  cfg["_live_url"] = live_url
@@ -966,25 +1048,35 @@ def cmd_start(args: argparse.Namespace) -> None:
966
1048
  print(f"Bridge: http://localhost:9877")
967
1049
  if live_url:
968
1050
  print(f"Live: {live_url}")
1051
+ if verbose:
1052
+ print(f"Mode: verbose (debug → daemon.log)")
1053
+ if streaming:
1054
+ print(f"Mode: streaming (blocking, events → stdout)")
1055
+
1056
+ # --streaming: blocking foreground mode with real-time event output
1057
+ if streaming:
1058
+ _run_foreground(sid, watch_dir, cfg, capture, live_url, verbose=True, streaming=True)
1059
+ return
969
1060
 
970
- # Daemonize: spawn a subprocess (safe on macOS — avoids CoreFoundation segfault from os.fork)
1061
+ # Daemonize (macOS/Linux)pass --verbose to daemon if set
971
1062
  if sys.platform != "win32":
972
1063
  import subprocess as _sp
973
1064
  daemon_log = config.DIR / "daemon.log"
974
1065
  log_fh = open(daemon_log, "a")
975
- proc = _sp.Popen(
976
- [sys.executable, "-m", "methodproof._daemon", sid, watch_dir],
977
- start_new_session=True,
978
- stdout=log_fh, stderr=log_fh, stdin=_sp.DEVNULL,
979
- )
1066
+ cmd = [sys.executable, "-m", "methodproof._daemon", sid, watch_dir]
1067
+ if verbose:
1068
+ cmd.append("--verbose")
1069
+ _log_step(f"Spawning daemon (log: {daemon_log})")
1070
+ proc = _sp.Popen(cmd, start_new_session=True, stdout=log_fh, stderr=log_fh, stdin=_sp.DEVNULL)
980
1071
  PIDFILE.write_text(str(proc.pid))
981
- # Verify daemon is alive after brief startup
1072
+ if verbose:
1073
+ _log_debug("daemon.spawned", pid=proc.pid, cmd=cmd)
982
1074
  time.sleep(1)
983
1075
  if proc.poll() is not None:
984
1076
  print(f"ERROR: Daemon exited immediately (code {proc.returncode}). Check {daemon_log}")
985
1077
  PIDFILE.unlink(missing_ok=True)
986
1078
  sys.exit(1)
987
- # Check extension auto-discovery (extension polls every ~6s)
1079
+ _log_step(f"Daemon alive (pid {proc.pid})")
988
1080
  if capture.get("browser", True):
989
1081
  import urllib.request as _ur
990
1082
  time.sleep(7)
@@ -1001,9 +1093,16 @@ def cmd_start(args: argparse.Namespace) -> None:
1001
1093
  return
1002
1094
 
1003
1095
  # Windows: foreground mode (no subprocess daemonization)
1096
+ _run_foreground(sid, watch_dir, cfg, capture, live_url, verbose=verbose, streaming=False)
1097
+
1098
+
1099
+ def _run_foreground(sid: str, watch_dir: str, cfg: dict, capture: dict,
1100
+ live_url: str, verbose: bool, streaming: bool) -> None:
1101
+ """Run capture agents in the foreground (blocking). Used by --streaming and Windows."""
1004
1102
  from methodproof.agents import base as _base
1005
- _base.init(sid, live=bool(live_url))
1103
+ _base.init(sid, live=bool(live_url), verbose=verbose, streaming=streaming)
1006
1104
  if capture.get("environment_analysis", True):
1105
+ _log_step("Scanning environment")
1007
1106
  try:
1008
1107
  from methodproof.analysis import scan_environment
1009
1108
  env_profile = scan_environment(watch_dir)
@@ -1020,19 +1119,31 @@ def cmd_start(args: argparse.Namespace) -> None:
1020
1119
  if files_enabled:
1021
1120
  from methodproof.agents import watcher
1022
1121
  threads.append(threading.Thread(target=watcher.start, args=(watch_dir, stop_event), daemon=True))
1122
+ _log_step("Agent: watcher")
1023
1123
  if capture.get("terminal_commands", True) or capture.get("test_results", True):
1024
1124
  from methodproof.agents import terminal
1025
1125
  threads.append(threading.Thread(target=terminal.start, args=(stop_event,), daemon=True))
1126
+ _log_step("Agent: terminal")
1026
1127
  if capture.get("browser", True):
1027
1128
  from methodproof import bridge
1129
+ e2e_key_hex = ""
1130
+ if cfg.get("_session_e2e") and cfg.get("account_id"):
1131
+ from methodproof.keychain import load_secret
1132
+ _raw = load_secret(f"e2e:{cfg['account_id']}")
1133
+ if _raw:
1134
+ e2e_key_hex = _raw.hex()
1135
+ else:
1136
+ e2e_key_hex = cfg.get("e2e_key", "")
1028
1137
  threads.append(threading.Thread(target=bridge.start, args=(
1029
1138
  sid, stop_event, 9877,
1030
- cfg.get("token", ""), cfg.get("api_url", ""), cfg.get("e2e_key", ""),
1139
+ cfg.get("token", ""), cfg.get("api_url", ""), e2e_key_hex,
1031
1140
  cfg.get("journal_mode", False),
1032
1141
  ), daemon=True))
1142
+ _log_step("Agent: bridge")
1033
1143
  if capture.get("music", True):
1034
1144
  from methodproof.agents import music
1035
1145
  threads.append(threading.Thread(target=music.start, args=(stop_event,), daemon=True))
1146
+ _log_step("Agent: music")
1036
1147
 
1037
1148
  def _shutdown(sig: int, frame: object) -> None:
1038
1149
  stop_event.set()
@@ -1054,10 +1165,14 @@ def cmd_start(args: argparse.Namespace) -> None:
1054
1165
  PIDFILE.unlink(missing_ok=True)
1055
1166
  sys.exit(0)
1056
1167
 
1168
+ _log_step(f"Starting {len(threads)} agent(s)")
1057
1169
  for t in threads:
1058
1170
  t.start()
1059
1171
  signal.signal(signal.SIGINT, _shutdown)
1060
- print("Press Ctrl+C or run `mp stop` to finish.")
1172
+ if streaming:
1173
+ print("\n Streaming events (Ctrl+C to stop):\n")
1174
+ else:
1175
+ print("Press Ctrl+C or run `mp stop` to finish.")
1061
1176
  stopfile = config.DIR / "methodproof.stop"
1062
1177
  while not stop_event.is_set():
1063
1178
  if stopfile.exists():
@@ -1176,6 +1291,8 @@ def cmd_login(args: argparse.Namespace) -> None:
1176
1291
  config.save(cfg)
1177
1292
  print(" done.\n")
1178
1293
  _setup_master_key(cfg)
1294
+ from methodproof.sync import sync_research_consent
1295
+ sync_research_consent(cfg["token"], cfg["api_url"])
1179
1296
  print("Logged in. Run `methodproof push` to upload sessions.")
1180
1297
  return
1181
1298
  except Exception:
@@ -1190,6 +1307,9 @@ def cmd_push(args: argparse.Namespace) -> None:
1190
1307
  if not cfg.get("token"):
1191
1308
  print("Run `methodproof login` first.")
1192
1309
  sys.exit(1)
1310
+ from methodproof.sync import sync_research_consent
1311
+ sync_research_consent(cfg["token"], cfg["api_url"])
1312
+ cfg = config.load()
1193
1313
  sid = args.session_id or _latest()
1194
1314
  if not sid:
1195
1315
  print("No sessions to push.")
@@ -1583,6 +1703,10 @@ def main() -> None:
1583
1703
  s.add_argument("--live", action="store_true", help="Stream graph live to your private profile")
1584
1704
  s.add_argument("--live-public", action="store_true", help="Stream graph live — visible to anyone with the link")
1585
1705
  s.add_argument("--journal", action="store_true", help="Journal mode — full content capture (2 free credits, then Pro)")
1706
+ s.add_argument("--e2e", action="store_true", help="E2E encryption — content encrypted with your personal key")
1707
+ s.add_argument("--no-e2e", action="store_true", help="Disable E2E for this session (overrides config)")
1708
+ s.add_argument("--verbose", "-v", action="store_true", help="Debug logging at each step (still daemonizes)")
1709
+ s.add_argument("--streaming", action="store_true", help="Blocking foreground — stream every captured event to stdout")
1586
1710
  sub.add_parser("stop", help="Stop recording")
1587
1711
  v = sub.add_parser("view", help="Inspect captured session data")
1588
1712
  v.add_argument("session_id", nargs="?")
@@ -1626,6 +1750,14 @@ def main() -> None:
1626
1750
  jr_sub.add_parser("on", help="Enable journal mode (persists full content)")
1627
1751
  jr_sub.add_parser("off", help="Disable journal mode (structural only)")
1628
1752
  jr_sub.add_parser("status", help="Show journal mode status")
1753
+ e2e_p = sub.add_parser("e2e", help="E2E encryption — personal key management")
1754
+ e2e_sub = e2e_p.add_subparsers(dest="e2e_cmd")
1755
+ e2e_sub.add_parser("on", help="Enable E2E mode (generates key on first use)")
1756
+ e2e_sub.add_parser("off", help="Disable E2E mode (key stays in keychain)")
1757
+ e2e_sub.add_parser("status", help="Show E2E mode status")
1758
+ e2e_sub.add_parser("recover", help="Recover key from passphrase")
1759
+ e2e_rel = e2e_sub.add_parser("release", help="Release a session from E2E encryption")
1760
+ e2e_rel.add_argument("session_id", help="Session ID to release")
1629
1761
  sub.add_parser("intro", help="Show the MethodProof intro")
1630
1762
  sub.add_parser("help", help="Show command reference")
1631
1763
  sub.add_parser("mcp-serve", help="Run MCP server (used by Claude Code)")
@@ -1645,6 +1777,7 @@ def main() -> None:
1645
1777
  "update": cmd_update, "lock": cmd_lock, "reset": cmd_reset, "uninstall": cmd_uninstall,
1646
1778
  "extension": cmd_extension,
1647
1779
  "journal": cmd_journal,
1780
+ "e2e": lambda a: __import__("methodproof.e2e", fromlist=["cmd_e2e"]).cmd_e2e(a),
1648
1781
  "intro": lambda _: _print_intro(),
1649
1782
  "help": lambda _: _print_commands(),
1650
1783
  "mcp-serve": cmd_mcp_serve,
@@ -34,11 +34,16 @@ _DEFAULTS: dict[str, Any] = {
34
34
  "code_capture": False,
35
35
  },
36
36
  "research_consent": False,
37
+ "contribution_level": None,
38
+ "_pending_research_sync": False,
37
39
  "journal_mode": False,
38
40
  "journal_credits": 2,
41
+ "e2e_mode": False,
42
+ "e2e_fingerprint": "",
39
43
  "auto_update": False,
40
44
  "account_id": "",
41
45
  "last_auth_at": 0,
46
+ "local_ai_ports": [], # user-configured localhost ports for local LLM capture
42
47
  "publish_redact": {
43
48
  "command_output": True,
44
49
  "ai_prompts": True,