methodproof 0.8.6__tar.gz → 0.8.8__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 (91) hide show
  1. {methodproof-0.8.6 → methodproof-0.8.8}/CHANGELOG.md +10 -0
  2. {methodproof-0.8.6 → methodproof-0.8.8}/PKG-INFO +1 -1
  3. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/watcher.py +33 -7
  4. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/cli.py +53 -1
  5. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/claude_code.py +1 -0
  6. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/repos.py +2 -1
  7. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/sync.py +57 -1
  8. {methodproof-0.8.6 → methodproof-0.8.8}/pyproject.toml +1 -1
  9. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_repos.py +18 -0
  10. methodproof-0.8.8/tests/test_watcher_git.py +41 -0
  11. {methodproof-0.8.6 → methodproof-0.8.8}/uv.lock +1 -1
  12. {methodproof-0.8.6 → methodproof-0.8.8}/.github/workflows/ci.yml +0 -0
  13. {methodproof-0.8.6 → methodproof-0.8.8}/.gitignore +0 -0
  14. {methodproof-0.8.6 → methodproof-0.8.8}/LICENSE +0 -0
  15. {methodproof-0.8.6 → methodproof-0.8.8}/README.md +0 -0
  16. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/__init__.py +0 -0
  17. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/__main__.py +0 -0
  18. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/_daemon.py +0 -0
  19. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/__init__.py +0 -0
  20. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/base.py +0 -0
  21. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/music.py +0 -0
  22. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/terminal.py +0 -0
  23. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/analysis.py +0 -0
  24. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/binding.py +0 -0
  25. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/bip39.py +0 -0
  26. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/bridge.py +0 -0
  27. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/config.py +0 -0
  28. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/crypto.py +0 -0
  29. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/e2e.py +0 -0
  30. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/graph.py +0 -0
  31. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hook.py +0 -0
  32. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/__init__.py +0 -0
  33. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/claude_code.sh +0 -0
  34. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/cline_hook.sh +0 -0
  35. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/codex_hook.sh +0 -0
  36. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/gemini_hook.sh +0 -0
  37. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/install.py +0 -0
  38. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/kiro_hook.sh +0 -0
  39. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/mcp_register.py +0 -0
  40. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/model_cache.py +0 -0
  41. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/openclaw/HOOK.md +0 -0
  42. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/openclaw/handler.ts +0 -0
  43. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/openclaw_install.py +0 -0
  44. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/opencode_plugin.js +0 -0
  45. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/wrappers.py +0 -0
  46. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/integrity.py +0 -0
  47. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/kdf.py +0 -0
  48. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/keychain.py +0 -0
  49. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/live.py +0 -0
  50. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/lock.py +0 -0
  51. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/mcp.py +0 -0
  52. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/migrate_db.py +0 -0
  53. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/proxy.py +0 -0
  54. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/proxy_daemon.py +0 -0
  55. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/skills/methodproof/SKILL.md +0 -0
  56. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/store.py +0 -0
  57. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/__init__.py +0 -0
  58. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/consent.py +0 -0
  59. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/init.py +0 -0
  60. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/log.py +0 -0
  61. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/login_success.py +0 -0
  62. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/review.py +0 -0
  63. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/start.py +0 -0
  64. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/status.py +0 -0
  65. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/theme.py +0 -0
  66. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/viewer.py +0 -0
  67. {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/wordlist.py +0 -0
  68. {methodproof-0.8.6 → methodproof-0.8.8}/test_windows_compat.py +0 -0
  69. {methodproof-0.8.6 → methodproof-0.8.8}/tests/__init__.py +0 -0
  70. {methodproof-0.8.6 → methodproof-0.8.8}/tests/conftest.py +0 -0
  71. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_analysis.py +0 -0
  72. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_auth.py +0 -0
  73. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_config.py +0 -0
  74. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_helpers.py +0 -0
  75. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_session.py +0 -0
  76. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_share.py +0 -0
  77. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_start.py +0 -0
  78. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_update.py +0 -0
  79. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_e2e_integration.py +0 -0
  80. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_graph.py +0 -0
  81. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_hooks.py +0 -0
  82. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_live.py +0 -0
  83. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_model_cache.py +0 -0
  84. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_openclaw_hooks.py +0 -0
  85. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_profiles.py +0 -0
  86. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_security.py +0 -0
  87. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_store.py +0 -0
  88. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_sync.py +0 -0
  89. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_viewer.py +0 -0
  90. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_watcher_ignore.py +0 -0
  91. {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_wrappers.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.8] — 2026-05-30
4
+
5
+ ### Fixed
6
+ - **Git telemetry inside worktrees** — the watcher and sub-repo enumeration both assumed `.git` is a directory, but in a `git worktree` it is a file pointing at the shared git dir. As a result, running `mp start` from a worktree captured zero `git_commit`/`git_branch_switch` events (the poller raised `NotADirectoryError` reading `.git/refs` and `.git/HEAD`, swallowed silently) and `mp push`/`mp resync` skipped the worktree's repo entirely. The poller now resolves the per-worktree git dir (HEAD) and shared common dir (refs) via `git rev-parse --absolute-git-dir --git-common-dir` (`methodproof/agents/watcher.py`), and sub-repo enumeration detects worktrees via `os.path.exists` rather than `os.path.isdir` (`methodproof/repos.py`).
7
+
8
+ ## [0.8.7] — 2026-05-15
9
+
10
+ ### Fixed
11
+ - **`SubagentStop` events now emit `last_message_preview`** (`methodproof/hooks/claude_code.py`) — the Python hook only emitted full `last_assistant_message`, so the dashboard's structural-only view of `agent_complete` events showed nothing for accounts without journal mode. Hook now emits both: full content (server gates by tier) plus a 200-char preview (always survives).
12
+
3
13
  ## [0.8.6] — 2026-04-24
4
14
 
5
15
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.8.6
3
+ Version: 0.8.8
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
@@ -268,13 +268,39 @@ def _read_branch(head_file: Path) -> str:
268
268
  return ""
269
269
 
270
270
 
271
+ def _resolve_git_dirs(watch_dir: str) -> tuple[Path, Path] | None:
272
+ """Resolve (git_dir, common_dir) for `watch_dir`, or None if not a git repo.
273
+
274
+ HEAD lives in git_dir; shared refs live in common_dir. The two diverge
275
+ inside a git worktree, where `.git` is a file rather than a directory.
276
+ """
277
+ try:
278
+ out = subprocess.run(
279
+ ["git", "-C", watch_dir, "rev-parse", "--absolute-git-dir", "--git-common-dir"],
280
+ capture_output=True, text=True, timeout=5,
281
+ )
282
+ except (FileNotFoundError, subprocess.TimeoutExpired):
283
+ return None
284
+ if out.returncode != 0:
285
+ return None
286
+ lines = out.stdout.splitlines()
287
+ if len(lines) < 2:
288
+ return None
289
+ git_dir = Path(lines[0].strip())
290
+ common_dir = Path(lines[1].strip())
291
+ if not common_dir.is_absolute():
292
+ common_dir = (Path(watch_dir) / common_dir).resolve()
293
+ return git_dir, common_dir
294
+
295
+
271
296
  def _poll_git(watch_dir: str, stop: threading.Event) -> None:
272
- """Poll .git/refs for new commits and HEAD for branch switches."""
273
- git_dir = Path(watch_dir) / ".git"
274
- if not git_dir.exists():
297
+ """Poll shared refs for new commits and HEAD for branch switches."""
298
+ dirs = _resolve_git_dirs(watch_dir)
299
+ if dirs is None:
275
300
  return
301
+ git_dir, common_dir = dirs
276
302
  seen: set[str] = set()
277
- refs = git_dir / "refs" / "heads"
303
+ refs = common_dir / "refs" / "heads"
278
304
  head_file = git_dir / "HEAD"
279
305
  last_branch = _read_branch(head_file)
280
306
  while not stop.is_set():
@@ -284,7 +310,7 @@ def _poll_git(watch_dir: str, stop: threading.Event) -> None:
284
310
  if sha not in seen:
285
311
  seen.add(sha)
286
312
  if len(seen) > 1:
287
- _log_commit(watch_dir, sha)
313
+ _log_commit(watch_dir, sha, head_file)
288
314
  except OSError:
289
315
  pass
290
316
  current_branch = _read_branch(head_file)
@@ -296,7 +322,7 @@ def _poll_git(watch_dir: str, stop: threading.Event) -> None:
296
322
  stop.wait(2)
297
323
 
298
324
 
299
- def _log_commit(watch_dir: str, sha: str) -> None:
325
+ def _log_commit(watch_dir: str, sha: str, head_file: Path) -> None:
300
326
  try:
301
327
  fmt = subprocess.run(
302
328
  ["git", "-C", watch_dir, "log", "-1",
@@ -336,7 +362,7 @@ def _log_commit(watch_dir: str, sha: str) -> None:
336
362
  meta["body"] = body
337
363
  if file_statuses:
338
364
  meta["file_statuses"] = file_statuses
339
- branch = _read_branch(Path(watch_dir) / ".git" / "HEAD")
365
+ branch = _read_branch(head_file)
340
366
  if branch:
341
367
  meta["branch"] = branch
342
368
  include_lines = base.is_content_captured()
@@ -1929,6 +1929,54 @@ def _push_one(sid: str, cfg: dict, force: bool = False) -> None:
1929
1929
  print(f" Publish: mp publish {sid[:8]}")
1930
1930
 
1931
1931
 
1932
+ def cmd_resync(args: argparse.Namespace) -> None:
1933
+ """Re-push already-synced sessions to overwrite server ciphertext with
1934
+ plaintext. Use when a past migration encrypted sensitive fields with a
1935
+ key the server can't read."""
1936
+ local = getattr(args, "local", False)
1937
+ cfg = config.load(local=local)
1938
+ if not cfg.get("token"):
1939
+ print("Run `methodproof login` first.")
1940
+ sys.exit(1)
1941
+ if cfg.get("e2e_mode"):
1942
+ print("E2E mode is ON — resync would preserve ciphertext. Turn off with `mp e2e off` first.")
1943
+ sys.exit(1)
1944
+
1945
+ from methodproof.sync import resync_events
1946
+
1947
+ sessions = store.list_sessions()
1948
+ candidates = [s for s in sessions if s.get("synced") and s.get("remote_id")]
1949
+
1950
+ if args.session_id:
1951
+ pref = args.session_id
1952
+ candidates = [s for s in candidates
1953
+ if s["id"].startswith(pref) or (s.get("remote_id") or "").startswith(pref)]
1954
+ if not candidates:
1955
+ print(f"No synced session matches {args.session_id}")
1956
+ sys.exit(1)
1957
+ elif args.since:
1958
+ from datetime import datetime
1959
+ cutoff = datetime.fromisoformat(args.since).timestamp()
1960
+ candidates = [s for s in candidates
1961
+ if (s.get("completed_at") or s.get("created_at") or 0) >= cutoff]
1962
+
1963
+ if not candidates:
1964
+ print("No synced sessions to resync.")
1965
+ return
1966
+
1967
+ total_events = 0
1968
+ print(f"Resyncing {len(candidates)} sessions (decrypting sensitive fields on push)...")
1969
+ for s in candidates:
1970
+ try:
1971
+ n = resync_events(s, cfg["token"], cfg["api_url"])
1972
+ total_events += n
1973
+ date = datetime.fromtimestamp(s["created_at"]).strftime("%Y-%m-%d") if s.get("created_at") else "?"
1974
+ print(f" {s['id'][:8]} {date} {n:5} events resynced")
1975
+ except SystemExit as exc:
1976
+ print(f" {s['id'][:8]} FAILED: {exc}")
1977
+ print(f"\nDone. {total_events} events resynced across {len(candidates)} sessions.")
1978
+
1979
+
1932
1980
  def cmd_tag(args: argparse.Namespace) -> None:
1933
1981
  session = _resolve_session(args.session_id)
1934
1982
  tags = [t.strip() for t in args.tags.split(",") if t.strip()]
@@ -2356,6 +2404,10 @@ def main() -> None:
2356
2404
  pu.add_argument("session_id", nargs="?")
2357
2405
  pu.add_argument("--force", "-f", action="store_true", help="Re-upload all events (replaces previous push)")
2358
2406
  pu.add_argument("--local", action="store_true", help="Push to local dev API (localhost:8000)")
2407
+ rs_ev = sub.add_parser("resync", help="Re-push already-synced sessions to decrypt sensitive fields server-side")
2408
+ rs_ev.add_argument("session_id", nargs="?", help="Session ID prefix (default: all synced)")
2409
+ rs_ev.add_argument("--since", help="Only resync sessions started on or after this date (YYYY-MM-DD)")
2410
+ rs_ev.add_argument("--local", action="store_true", help="Resync against local dev API")
2359
2411
  tg = sub.add_parser("tag", help="Tag a session")
2360
2412
  tg.add_argument("session_id", help="Session ID (prefix ok)")
2361
2413
  tg.add_argument("tags", help="Comma-separated tags")
@@ -2422,7 +2474,7 @@ def main() -> None:
2422
2474
  "init": cmd_init, "start": cmd_start, "stop": cmd_stop, "connect": cmd_connect,
2423
2475
  "view": cmd_view, "log": cmd_log, "status": cmd_status,
2424
2476
  "login": cmd_login, "logout": cmd_logout, "accounts": cmd_accounts, "switch": cmd_switch,
2425
- "push": cmd_push, "tag": cmd_tag, "publish": cmd_publish,
2477
+ "push": cmd_push, "resync": cmd_resync, "tag": cmd_tag, "publish": cmd_publish,
2426
2478
  "delete": cmd_delete, "review": cmd_review, "consent": cmd_consent,
2427
2479
  "update": cmd_update, "lock": cmd_lock, "reset": cmd_reset, "uninstall": cmd_uninstall,
2428
2480
  "extension": cmd_extension,
@@ -146,6 +146,7 @@ _META_EXTRACTORS = {
146
146
  "SubagentStop": lambda d: {
147
147
  "tool": _TOOL, "agent_type": d.get("agent_type", "unknown"), "agent_id": d.get("agent_id", ""),
148
148
  "last_assistant_message": d.get("last_assistant_message", ""),
149
+ "last_message_preview": (d.get("last_assistant_message") or "")[:200],
149
150
  },
150
151
  "TaskCreated": lambda d: {"tool": _TOOL, "task_id": d.get("task_id", ""), "subject": d.get("task_subject", "")},
151
152
  "TaskCompleted": lambda d: {"tool": _TOOL, "task_id": d.get("task_id", "")},
@@ -26,7 +26,8 @@ def enumerate_sub_repos(watch_dir: str, max_depth: int = 2) -> list[dict[str, st
26
26
  def visit(path: str, depth: int) -> None:
27
27
  if not os.path.isdir(path):
28
28
  return
29
- if os.path.isdir(os.path.join(path, ".git")):
29
+ # `.git` is a directory in a normal clone, a file in a worktree.
30
+ if os.path.exists(os.path.join(path, ".git")):
30
31
  url = _remote_url(path)
31
32
  if url and url not in seen_urls:
32
33
  rel = os.path.relpath(path, watch_dir)
@@ -9,6 +9,34 @@ from typing import Any
9
9
  from methodproof import store
10
10
 
11
11
 
12
+ def _prepare_events_for_push(session_id: str) -> list[dict[str, Any]]:
13
+ """Return events for outbound sync. Sensitive fields encrypted with the
14
+ local master-derived db_key are decrypted here so the server receives
15
+ plaintext. Users who opt into true E2E (`mp e2e on`) keep ciphertext
16
+ verbatim — they explicitly chose platform-unreadable storage.
17
+
18
+ Local-DB at-rest encryption and E2E share the `e2e:v1:` wire format,
19
+ so without this step the server can't tell them apart and shows
20
+ ciphertext for normal (non-E2E) users."""
21
+ raw = store.get_events(session_id, decrypt=False)
22
+ from methodproof import config
23
+ cfg = config.load()
24
+ if cfg.get("e2e_mode"):
25
+ return raw
26
+ key = store._try_load_db_key()
27
+ if key is None:
28
+ return raw
29
+ from methodproof.crypto import decrypt_metadata_safe
30
+ prepared = []
31
+ for e in raw:
32
+ meta = json.loads(e["metadata"])
33
+ decrypt_metadata_safe(meta, key)
34
+ e2 = dict(e)
35
+ e2["metadata"] = json.dumps(meta)
36
+ prepared.append(e2)
37
+ return prepared
38
+
39
+
12
40
  def sync_metadata(session: dict[str, Any], token: str, api_url: str) -> None:
13
41
  """Sync repo, tags, and visibility for an already-pushed session."""
14
42
  remote_id = session.get("remote_id")
@@ -127,7 +155,7 @@ def push(session_id: str, token: str, api_url: str, force: bool = False) -> str:
127
155
  print(f"done ({remote_id[:8]})")
128
156
 
129
157
  # Upload events in batches (with hash chain if available)
130
- events = store.get_events(session_id)
158
+ events = _prepare_events_for_push(session_id)
131
159
  event_hashes = store.get_event_hashes(session_id)
132
160
  hash_lookup = {h["event_id"]: h["hash"] for h in event_hashes}
133
161
  total = len(events)
@@ -214,6 +242,34 @@ def _iso(ts: float) -> str:
214
242
  return datetime.fromtimestamp(ts, tz=UTC).isoformat()
215
243
 
216
244
 
245
+ def resync_events(session: dict[str, Any], token: str, api_url: str) -> int:
246
+ """Re-push events for an already-synced session so server metadata reflects
247
+ the current local state. Uses the dedicated `/events/resync` endpoint
248
+ (accepts completed sessions; updates existing Action nodes in place).
249
+ Returns the number of events submitted."""
250
+ remote_id = session.get("remote_id")
251
+ if not remote_id:
252
+ raise SystemExit(f"Session {session['id'][:8]} has no remote_id — push it first.")
253
+ events = _prepare_events_for_push(session["id"])
254
+ total = len(events)
255
+ batch_size = 500
256
+ for i in range(0, total, batch_size):
257
+ batch = events[i:i + batch_size]
258
+ payload = [{"id": e["id"], "metadata": json.loads(e["metadata"])} for e in batch]
259
+ for attempt in range(5):
260
+ try:
261
+ _request("POST", f"/personal/sessions/{remote_id}/events/resync",
262
+ api_url, token, {"events": payload})
263
+ break
264
+ except SystemExit as exc:
265
+ if "429" in str(exc) and attempt < 4:
266
+ import time
267
+ time.sleep(10 * (attempt + 1))
268
+ else:
269
+ raise
270
+ return total
271
+
272
+
217
273
  def sync_research_consent(token: str, api_url: str) -> None:
218
274
  """Sync research consent between CLI (cache) and platform (source of truth)."""
219
275
  from methodproof import config
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "methodproof"
3
- version = "0.8.6"
3
+ version = "0.8.8"
4
4
  description = "See how you code. Capture and visualize your engineering process."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = ["watchdog>=4.0", "websocket-client>=1.7", "cryptography>=46.0.7", "keyring>=25.0", "textual>=0.59", "rich>=13.7", "sqlcipher3>=0.6"]
@@ -12,6 +12,9 @@ from methodproof import repos
12
12
  def _git_init(path: Path, remote: str) -> None:
13
13
  path.mkdir(parents=True, exist_ok=True)
14
14
  subprocess.run(["git", "init", "-q"], cwd=path, check=True)
15
+ # CI runners have no global git identity; commits need a local one.
16
+ subprocess.run(["git", "config", "user.email", "test@methodproof.com"], cwd=path, check=True)
17
+ subprocess.run(["git", "config", "user.name", "test"], cwd=path, check=True)
15
18
  subprocess.run(["git", "remote", "add", "origin", remote], cwd=path, check=True)
16
19
 
17
20
 
@@ -71,3 +74,18 @@ def test_enumerate_sub_repos_skips_non_repo_dirs(tmp_path: Path) -> None:
71
74
  assert "node_modules" not in rels
72
75
  assert "not-a-repo" not in rels
73
76
  assert "real" in rels
77
+
78
+
79
+ def test_enumerate_sub_repos_detects_worktree(tmp_path: Path) -> None:
80
+ main = tmp_path / "main"
81
+ _git_init(main, "https://github.com/me/wt")
82
+ subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"],
83
+ cwd=main, check=True)
84
+ wt = tmp_path / "wt-feature"
85
+ subprocess.run(["git", "worktree", "add", "-q", str(wt), "-b", "feature"],
86
+ cwd=main, check=True)
87
+
88
+ # `.git` is a file in the worktree, not a directory.
89
+ assert (wt / ".git").is_file()
90
+ found = repos.enumerate_sub_repos(str(wt), max_depth=1)
91
+ assert found == [{"remote_url": "https://github.com/me/wt", "rel_path": ""}]
@@ -0,0 +1,41 @@
1
+ """Tests for git dir resolution — normal clones vs worktrees."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from methodproof.agents import watcher
7
+
8
+
9
+ def _git_init(path: Path) -> None:
10
+ path.mkdir(parents=True, exist_ok=True)
11
+ subprocess.run(["git", "init", "-q"], cwd=path, check=True)
12
+ # CI runners have no global git identity; commits need a local one.
13
+ subprocess.run(["git", "config", "user.email", "test@methodproof.com"], cwd=path, check=True)
14
+ subprocess.run(["git", "config", "user.name", "test"], cwd=path, check=True)
15
+ subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"],
16
+ cwd=path, check=True)
17
+
18
+
19
+ def test_resolve_git_dirs_normal_repo(tmp_path: Path) -> None:
20
+ _git_init(tmp_path)
21
+ git_dir, common_dir = watcher._resolve_git_dirs(str(tmp_path))
22
+ assert git_dir == common_dir
23
+ assert (git_dir / "HEAD").is_file()
24
+
25
+
26
+ def test_resolve_git_dirs_worktree(tmp_path: Path) -> None:
27
+ main = tmp_path / "main"
28
+ _git_init(main)
29
+ wt = tmp_path / "wt-feature"
30
+ subprocess.run(["git", "worktree", "add", "-q", str(wt), "-b", "feature"],
31
+ cwd=main, check=True)
32
+
33
+ git_dir, common_dir = watcher._resolve_git_dirs(str(wt))
34
+ # Worktree HEAD is per-worktree; shared refs live in the common dir.
35
+ assert git_dir != common_dir
36
+ assert watcher._read_branch(git_dir / "HEAD") == "feature"
37
+ assert (common_dir / "refs" / "heads" / "feature").is_file()
38
+
39
+
40
+ def test_resolve_git_dirs_non_repo(tmp_path: Path) -> None:
41
+ assert watcher._resolve_git_dirs(str(tmp_path)) is None
@@ -708,7 +708,7 @@ wheels = [
708
708
 
709
709
  [[package]]
710
710
  name = "methodproof"
711
- version = "0.8.5"
711
+ version = "0.8.7"
712
712
  source = { editable = "." }
713
713
  dependencies = [
714
714
  { name = "cryptography" },
File without changes
File without changes
File without changes