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.
- {methodproof-0.8.6 → methodproof-0.8.8}/CHANGELOG.md +10 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/PKG-INFO +1 -1
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/watcher.py +33 -7
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/cli.py +53 -1
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/claude_code.py +1 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/repos.py +2 -1
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/sync.py +57 -1
- {methodproof-0.8.6 → methodproof-0.8.8}/pyproject.toml +1 -1
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_repos.py +18 -0
- methodproof-0.8.8/tests/test_watcher_git.py +41 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/uv.lock +1 -1
- {methodproof-0.8.6 → methodproof-0.8.8}/.github/workflows/ci.yml +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/.gitignore +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/LICENSE +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/README.md +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/__init__.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/__main__.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/_daemon.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/base.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/music.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/agents/terminal.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/analysis.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/binding.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/bip39.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/bridge.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/config.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/crypto.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/e2e.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/graph.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hook.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/claude_code.sh +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/install.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/model_cache.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/integrity.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/kdf.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/keychain.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/live.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/lock.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/mcp.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/migrate_db.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/proxy.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/store.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/__init__.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/consent.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/init.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/log.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/login_success.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/review.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/start.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/status.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/tui/theme.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/viewer.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/methodproof/wordlist.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/test_windows_compat.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/__init__.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/conftest.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_analysis.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_auth.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_config.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_helpers.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_session.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_share.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_start.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_cli_update.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_e2e_integration.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_graph.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_hooks.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_live.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_model_cache.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_profiles.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_security.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_store.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_sync.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_viewer.py +0 -0
- {methodproof-0.8.6 → methodproof-0.8.8}/tests/test_watcher_ignore.py +0 -0
- {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
|
|
@@ -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
|
|
273
|
-
|
|
274
|
-
if
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|