diff --git a/new_session.py b/new_session.py index d86bef4..3d0acad 100644 --- a/new_session.py +++ b/new_session.py @@ -1012,6 +1012,11 @@ def get_newest_jsonl(logs_dir): def verify_claude_working(project_dir, timeout=45): + """Verify new Claude session is working by watching transcript logs. + + Returns the path to the new/active JSONL file on success, or None on timeout. + The returned path is truthy, so callers using `if verify_claude_working(...)` still work. + """ logs_dir = get_project_logs_dir(project_dir) baseline_file, baseline_size = get_newest_jsonl(logs_dir) log(f"Phase 2: watching transcript logs in {logs_dir}") @@ -1022,13 +1027,34 @@ def verify_claude_working(project_dir, timeout=45): if current_file and current_file != baseline_file: log(f"Verified: new session transcript detected ({os.path.basename(current_file)})") - return True + return current_file if current_file and current_size > baseline_size: log(f"Verified: transcript growing (+{current_size - baseline_size} bytes)") - return True + return current_file + + return None + - return False +def record_session_chain(project_dir, old_jsonl, new_jsonl): + """Append an old->new session transition record to session-chain.jsonl. + + Enables chat-export to stitch context-reset jumps into a continuous narrative. + """ + if not old_jsonl and not new_jsonl: + return + logs_dir = get_project_logs_dir(project_dir) + os.makedirs(logs_dir, exist_ok=True) + chain_file = os.path.join(logs_dir, "session-chain.jsonl") + record = { + "old_session": os.path.basename(old_jsonl) if old_jsonl else None, + "new_session": os.path.basename(new_jsonl) if new_jsonl else None, + "project_dir": os.path.abspath(project_dir), + "timestamp": datetime.now().isoformat(), + } + with open(chain_file, "a", encoding="utf-8") as f: + f.write(json.dumps(record) + "\n") + log(f"Recorded session chain: {record['old_session']} -> {record['new_session']}") # ============ Main ============ @@ -1212,10 +1238,15 @@ def _remove_lock(): _remove_lock() return + # Capture old session JSONL before verify (for chain recording) + old_jsonl, _ = get_newest_jsonl(get_project_logs_dir(launch_dir)) + # Phase 2: Verify working (check target project's logs, not source) - working = verify_claude_working(launch_dir, timeout=args.timeout) - if working: + new_jsonl = verify_claude_working(launch_dir, timeout=args.timeout) + if new_jsonl: log("New Claude confirmed working") + # Record the session chain for chat-export stitching + record_session_chain(launch_dir, old_jsonl, new_jsonl) shell_pid = find_shell_pid() if shell_pid: log(f"Closing old tab (shell PID {shell_pid})") diff --git a/scripts/test.py b/scripts/test.py index 54f2ee4..62826f2 100644 --- a/scripts/test.py +++ b/scripts/test.py @@ -308,11 +308,11 @@ def write_new_file(): t.start() result = context_reset.verify_claude_working(fake_project, timeout=5) t.join() - test("detects new transcript file", result is True) + test("detects new transcript file", result is not None and "session-new.jsonl" in result) # Simulate: no new activity within timeout result2 = context_reset.verify_claude_working(fake_project, timeout=2) - test("times out when no new activity", result2 is False) + test("times out when no new activity", result2 is None) # Simulate: existing file grows existing = os.path.join(fake_logs, "session-grow.jsonl") @@ -331,7 +331,7 @@ def grow_file(): t2.start() result3 = context_reset.verify_claude_working(fake_project, timeout=5) t2.join() - test("detects transcript growth", result3 is True) + test("detects transcript growth", result3 is not None and "session-grow.jsonl" in result3) context_reset.get_project_logs_dir = orig_fn @@ -369,6 +369,58 @@ def grow_file(): test("dry-run exits 0", result.returncode == 0) test("dry-run prints command", "DRY RUN" in result.stdout) +# --- record_session_chain --- +print("\n=== record_session_chain ===") +with tempfile.TemporaryDirectory() as d: + fake_project = os.path.join(d, "chain-project") + os.makedirs(fake_project) + logs_slug = os.path.abspath(fake_project).replace("\\", "-").replace("/", "-").replace(":", "-") + if logs_slug.startswith("-"): + logs_slug = logs_slug[1:] + fake_logs = os.path.join(d, "dotclaude", "projects", logs_slug) + os.makedirs(fake_logs) + + orig_fn = context_reset.get_project_logs_dir + context_reset.get_project_logs_dir = lambda proj: fake_logs + + # Test: writes correct JSONL record + context_reset.record_session_chain(fake_project, "/logs/old-session.jsonl", "/logs/new-session.jsonl") + chain_file = os.path.join(fake_logs, "session-chain.jsonl") + test("creates session-chain.jsonl", os.path.exists(chain_file)) + with open(chain_file) as fh: + lines = fh.readlines() + test("writes one JSONL line", len(lines) == 1) + record = json.loads(lines[0]) + test("old_session is basename only", record["old_session"] == "old-session.jsonl") + test("new_session is basename only", record["new_session"] == "new-session.jsonl") + test("has project_dir", "chain-project" in record["project_dir"]) + test("has timestamp", "T" in record["timestamp"]) + + # Test: appends (doesn't overwrite) + context_reset.record_session_chain(fake_project, "/logs/second-old.jsonl", "/logs/second-new.jsonl") + with open(chain_file) as fh: + lines = fh.readlines() + test("appends second record", len(lines) == 2) + record2 = json.loads(lines[1]) + test("second record has correct old", record2["old_session"] == "second-old.jsonl") + + # Test: handles None old_jsonl (first session in project) + context_reset.record_session_chain(fake_project, None, "/logs/first.jsonl") + with open(chain_file) as fh: + lines = fh.readlines() + test("handles None old_jsonl", len(lines) == 3) + record3 = json.loads(lines[2]) + test("old_session is null when None", record3["old_session"] is None) + test("new_session still recorded", record3["new_session"] == "first.jsonl") + + # Test: skips when both are None + context_reset.record_session_chain(fake_project, None, None) + with open(chain_file) as fh: + lines = fh.readlines() + test("skips when both None", len(lines) == 3) + + context_reset.get_project_logs_dir = orig_fn + # --- Summary --- print(f"\n{'='*40}") print(f"Results: {PASS} passed, {FAIL} failed")