contexthub-cli 0.1.0

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.
@@ -0,0 +1,1331 @@
1
+ """ContextHub CLI — ch init, ch commit, ch spawn, ch resolve, ch review-resolution, ch accept-resolution, ch search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import time
8
+ import urllib.request
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ from contexthub.core import git as gitops
16
+ from contexthub.core.context import ContextError, capture_context, find_latest_session, parse_session_jsonl, truncate_context
17
+ from contexthub.core.git import GitError
18
+ from contexthub.core.github import parse_github_remote
19
+ from contexthub.core.models import ContextRecord, RepoConfig, ResolutionRecord
20
+ from contexthub.core.r2 import R2Error, get_resolution, upload_context, upload_resolution, update_resolution
21
+
22
+
23
+ @click.group()
24
+ def cli():
25
+ """ContextHub — intent-first version control on top of git."""
26
+ pass
27
+
28
+
29
+ # ---------- ch init ----------
30
+
31
+
32
+ @cli.command()
33
+ @click.argument("url", required=False, default=None)
34
+ def init(url: str | None):
35
+ """Initialize ContextHub in the current directory.
36
+
37
+ Accepts a GitHub URL, owner/repo shorthand, or auto-detects from git remote.
38
+
39
+ \b
40
+ Examples:
41
+ ch init https://github.com/org/repo.git
42
+ ch init git@github.com:org/repo.git
43
+ ch init org/repo
44
+ ch init (auto-detect from existing remote)
45
+ """
46
+ # 1. Init git repo if needed
47
+ if not gitops.is_inside_work_tree():
48
+ click.echo("[1/6] Initializing git repository...")
49
+ try:
50
+ gitops.init_repo(cwd=Path.cwd())
51
+ click.echo(" Done.")
52
+ except GitError as e:
53
+ click.echo(f" Error: {e}", err=True)
54
+ sys.exit(1)
55
+ else:
56
+ click.echo("[1/6] Git repository found.")
57
+
58
+ # 2. Find git root
59
+ git_root = gitops.get_toplevel()
60
+
61
+ # 3. Already initialized?
62
+ ch_dir = git_root / ".ch"
63
+ if ch_dir.is_dir():
64
+ click.echo("Already initialized.")
65
+ return
66
+
67
+ # 4. Determine owner/repo
68
+ click.echo("[2/6] Configuring repository...")
69
+ if url:
70
+ # Try parsing as a GitHub URL first
71
+ parsed = parse_github_remote(url)
72
+ if parsed:
73
+ owner, repo_name = parsed
74
+ remote_url = url
75
+ else:
76
+ # Try as owner/repo shorthand
77
+ parts = url.split("/")
78
+ if len(parts) == 2 and parts[0] and parts[1]:
79
+ owner, repo_name = parts
80
+ remote_url = f"https://github.com/{owner}/{repo_name}.git"
81
+ else:
82
+ click.echo(
83
+ " Error: Provide a GitHub URL or owner/repo format.",
84
+ err=True,
85
+ )
86
+ sys.exit(1)
87
+
88
+ remote_name = "origin"
89
+ click.echo(f" Repo: {owner}/{repo_name}")
90
+
91
+ # Add git remote if it doesn't exist
92
+ try:
93
+ existing = gitops.get_remote_url("origin", cwd=git_root)
94
+ click.echo(f" Remote origin already set: {existing}")
95
+ except GitError:
96
+ try:
97
+ gitops.add_remote("origin", remote_url, cwd=git_root)
98
+ click.echo(f" Added remote origin -> {remote_url}")
99
+ except GitError:
100
+ pass
101
+ else:
102
+ # Auto-detect from existing remote
103
+ try:
104
+ remote_url = gitops.get_remote_url("origin", cwd=git_root)
105
+ except GitError:
106
+ click.echo(
107
+ " Error: No remote found. Pass a GitHub URL:",
108
+ err=True,
109
+ )
110
+ click.echo(
111
+ " ch init https://github.com/owner/repo.git",
112
+ err=True,
113
+ )
114
+ sys.exit(1)
115
+
116
+ parsed = parse_github_remote(remote_url)
117
+ if parsed is None:
118
+ click.echo(
119
+ " Error: Could not parse remote URL. Pass a GitHub URL.",
120
+ err=True,
121
+ )
122
+ sys.exit(1)
123
+ owner, repo_name = parsed
124
+ remote_name = "origin"
125
+ click.echo(f" Detected: {owner}/{repo_name} from remote")
126
+
127
+ # 5. Create .ch/ directory and config
128
+ click.echo("[3/6] Creating .ch/config.json...")
129
+ ch_dir.mkdir(parents=True, exist_ok=True)
130
+ config = RepoConfig(
131
+ owner=owner,
132
+ repo=repo_name,
133
+ remote_url=remote_url,
134
+ remote_name=remote_name,
135
+ )
136
+ config_path = ch_dir / "config.json"
137
+ config_path.write_text(config.to_json() + "\n", encoding="utf-8")
138
+ click.echo(" Done.")
139
+
140
+ # 6. Add .ch/ to .gitignore
141
+ click.echo("[4/6] Updating .gitignore...")
142
+ gitignore_path = git_root / ".gitignore"
143
+ if gitignore_path.exists():
144
+ content = gitignore_path.read_text(encoding="utf-8")
145
+ if ".ch/" not in content and ".ch\n" not in content:
146
+ with open(gitignore_path, "a", encoding="utf-8") as f:
147
+ if not content.endswith("\n"):
148
+ f.write("\n")
149
+ f.write(".ch/\n")
150
+ click.echo(" Added .ch/ to .gitignore")
151
+ else:
152
+ click.echo(" .ch/ already in .gitignore")
153
+ else:
154
+ gitignore_path.write_text(".ch/\n", encoding="utf-8")
155
+ click.echo(" Created .gitignore with .ch/")
156
+
157
+ # 7. Install Claude Code hooks in .claude/settings.json
158
+ click.echo("[5/6] Installing Claude Code hooks...")
159
+ _install_hooks(git_root)
160
+
161
+ # 8. Add agent instructions to CLAUDE.md
162
+ click.echo("[6/7] Adding agent instructions to CLAUDE.md...")
163
+ _install_claude_md(git_root)
164
+
165
+ # 9. Success
166
+ click.echo(f"[7/7] ContextHub initialized — connected to {owner}/{repo_name}")
167
+
168
+
169
+ # ---------- ch watch ----------
170
+
171
+
172
+ @cli.command()
173
+ def watch():
174
+ """Live-tail the current Claude Code session transcript."""
175
+ git_root = _find_ch_root()
176
+ if git_root is None:
177
+ # Fall back to git root even without .ch/
178
+ try:
179
+ git_root = gitops.get_toplevel()
180
+ except GitError:
181
+ click.echo("Error: Not in a git repository.", err=True)
182
+ sys.exit(1)
183
+
184
+ result = find_latest_session(git_root)
185
+ if result is None:
186
+ click.echo("No active Claude Code session found for this project.", err=True)
187
+ sys.exit(1)
188
+
189
+ session_path, session_id = result
190
+ click.echo(f"Watching session: {session_id}")
191
+ click.echo(f"File: {session_path}")
192
+ click.echo("─" * 60)
193
+
194
+ last_size = 0
195
+ last_line_count = 0
196
+
197
+ try:
198
+ while True:
199
+ try:
200
+ current_size = session_path.stat().st_size
201
+ except FileNotFoundError:
202
+ click.echo("\nSession file removed.")
203
+ break
204
+
205
+ if current_size != last_size:
206
+ last_size = current_size
207
+ transcript = parse_session_jsonl(session_path)
208
+ lines = transcript.split("\n")
209
+ if len(lines) > last_line_count:
210
+ # Print only new lines
211
+ new_text = "\n".join(lines[last_line_count:])
212
+ click.echo(new_text)
213
+ last_line_count = len(lines)
214
+
215
+ time.sleep(1)
216
+ except KeyboardInterrupt:
217
+ click.echo("\nStopped watching.")
218
+
219
+
220
+ # ---------- ch spawn ----------
221
+
222
+
223
+ @cli.command()
224
+ @click.argument("commit_sha")
225
+ def spawn(commit_sha: str):
226
+ """Spawn a new Claude Code agent with context from a previous commit."""
227
+ import os
228
+ import tempfile
229
+
230
+ from contexthub.core.r2 import get_commit_context
231
+
232
+ # 1. Find config
233
+ git_root = _find_ch_root()
234
+ if git_root is None:
235
+ click.echo("Error: Not a ContextHub repo. Run 'ch init' first.", err=True)
236
+ sys.exit(1)
237
+
238
+ config_path = git_root / ".ch" / "config.json"
239
+ try:
240
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
241
+ config = RepoConfig.from_dict(config_data)
242
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
243
+ click.echo(f"Error: Invalid .ch/config.json: {e}", err=True)
244
+ sys.exit(1)
245
+
246
+ # 2. Fetch commit context from R2
247
+ click.echo(f"Fetching context for {commit_sha}...")
248
+ try:
249
+ record = get_commit_context(config.owner, config.repo, commit_sha)
250
+ except Exception as e:
251
+ click.echo(f"Error: {e}", err=True)
252
+ sys.exit(1)
253
+
254
+ full_sha = record.get("git_commit_hash", commit_sha)
255
+ message = record.get("commit_message", "")
256
+ files = record.get("files_changed", [])
257
+ transcript = record.get("raw_context", "")
258
+
259
+ if not transcript:
260
+ click.echo("Error: No transcript found in this commit record.", err=True)
261
+ sys.exit(1)
262
+
263
+ # 3. Write context to temp file for the agent to read
264
+ context_file = Path(tempfile.mktemp(prefix=f"ch-context-{full_sha[:8]}-", suffix=".md"))
265
+ context_file.write_text(
266
+ f"# Context from commit {full_sha[:8]}\n\n"
267
+ f"**Commit message:** {message}\n"
268
+ f"**Files changed:** {', '.join(files)}\n"
269
+ f"**Branch:** {record.get('branch', 'unknown')}\n\n"
270
+ f"## Session Transcript\n\n{transcript}\n",
271
+ encoding="utf-8",
272
+ )
273
+
274
+ click.echo(f"Commit: {full_sha[:8]}")
275
+ click.echo(f"Message: {message}")
276
+ click.echo(f"Files: {len(files)} changed")
277
+ size_kb = len(transcript.encode("utf-8")) / 1024
278
+ click.echo(f"Context: {size_kb:.1f}KB")
279
+ click.echo(f"Written: {context_file}")
280
+ click.echo("Launching Claude Code agent...\n")
281
+
282
+ # 4. Launch claude with the context
283
+ initial_prompt = (
284
+ f"Read the file {context_file} — it contains the full session context "
285
+ f"from commit {full_sha[:8]} ({message}). "
286
+ f"Use this to understand what was done and continue working."
287
+ )
288
+ os.execlp("claude", "claude", initial_prompt)
289
+
290
+
291
+ # ---------- ch resolve ----------
292
+
293
+
294
+ @cli.command()
295
+ @click.argument("local_sha")
296
+ @click.argument("remote_sha", required=False, default=None)
297
+ @click.option("--resolution-id", default=None, help="Reuse an existing resolution record instead of creating a new one.")
298
+ def resolve(local_sha: str, remote_sha: str | None, resolution_id: str | None):
299
+ """Spawn a resolver agent with context from both sides of a merge conflict."""
300
+ import os
301
+ import tempfile
302
+
303
+ from contexthub.core.r2 import get_commit_context
304
+
305
+ # 1. Find config
306
+ git_root = _find_ch_root()
307
+ if git_root is None:
308
+ click.echo("Error: Not a ContextHub repo. Run 'ch init' first.", err=True)
309
+ sys.exit(1)
310
+
311
+ config_path = git_root / ".ch" / "config.json"
312
+ try:
313
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
314
+ config = RepoConfig.from_dict(config_data)
315
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
316
+ click.echo(f"Error: Invalid .ch/config.json: {e}", err=True)
317
+ sys.exit(1)
318
+
319
+ # 2. Fetch context for local commit and write to separate temp file
320
+ click.echo(f"Fetching context for local commit {local_sha}...")
321
+ try:
322
+ local_record = get_commit_context(config.owner, config.repo, local_sha)
323
+ except Exception as e:
324
+ click.echo(f"Error fetching local context: {e}", err=True)
325
+ sys.exit(1)
326
+
327
+ local_message = local_record.get("commit_message", "")
328
+ local_files = local_record.get("files_changed", [])
329
+ local_full_sha = local_record.get("git_commit_hash", local_sha)
330
+
331
+ # Write local context to separate temp file
332
+ local_context_file = Path(tempfile.mktemp(prefix=f"ch-context-local-{local_full_sha[:8]}-", suffix=".md"))
333
+ local_context_file.write_text(_format_commit_context(local_record, "local"), encoding="utf-8")
334
+ click.echo(f"Local context: {local_context_file}")
335
+
336
+ # 3. Fetch context for remote commit and write to separate temp file
337
+ remote_context_file = None
338
+ remote_full_sha = ""
339
+ if remote_sha:
340
+ click.echo(f"Fetching context for remote commit {remote_sha}...")
341
+ try:
342
+ remote_record = get_commit_context(config.owner, config.repo, remote_sha)
343
+ remote_full_sha = remote_record.get("git_commit_hash", remote_sha)
344
+ remote_context_file = Path(tempfile.mktemp(prefix=f"ch-context-remote-{remote_full_sha[:8]}-", suffix=".md"))
345
+ remote_context_file.write_text(_format_commit_context(remote_record, "remote"), encoding="utf-8")
346
+ click.echo(f"Remote context: {remote_context_file}")
347
+ except Exception as e:
348
+ click.echo(f"Warning: Could not fetch remote context: {e}", err=True)
349
+
350
+ # 4. Start the rebase so the conflict markers are in the working tree
351
+ click.echo("Starting rebase to surface conflicts...")
352
+ try:
353
+ gitops.fetch(cwd=git_root)
354
+ gitops.pull_rebase(cwd=git_root)
355
+ # No conflict — rebase was clean
356
+ click.echo("No conflicts found — rebase succeeded cleanly.")
357
+ try:
358
+ gitops.push(cwd=git_root)
359
+ click.echo("Pushed to remote.")
360
+ except GitError as e:
361
+ click.echo(f"Warning: Push failed: {e}", err=True)
362
+ return
363
+ except GitError as e:
364
+ if "rebase_conflict" not in str(e):
365
+ click.echo(f"Error during rebase: {e}", err=True)
366
+ sys.exit(1)
367
+
368
+ # 5. Get conflicted files
369
+ conflicted = gitops.get_conflicted_files(cwd=git_root)
370
+ click.echo(f"Conflicts in {len(conflicted)} file(s):")
371
+ for f in conflicted:
372
+ click.echo(f" {f}")
373
+
374
+ # 6. Create or reuse resolution record
375
+ if resolution_id:
376
+ click.echo(f"Reusing resolution record: {resolution_id}")
377
+ try:
378
+ update_resolution(config.owner, config.repo, resolution_id, {"status": "in_progress"})
379
+ except R2Error as e:
380
+ click.echo(f"Warning: Could not update resolution record: {e}", err=True)
381
+ else:
382
+ resolution_id = uuid.uuid4().hex[:12]
383
+ resolution = ResolutionRecord(
384
+ id=resolution_id,
385
+ owner=config.owner,
386
+ repo=config.repo,
387
+ local_sha=local_full_sha,
388
+ remote_sha=remote_full_sha or "",
389
+ session_id=None,
390
+ status="in_progress",
391
+ started_at=datetime.now(timezone.utc).isoformat(),
392
+ conflicted_files=conflicted,
393
+ repo_path=str(git_root),
394
+ )
395
+ try:
396
+ upload_resolution(config.owner, config.repo, resolution_id, resolution.to_dict())
397
+ click.echo(f"Resolution: {resolution_id}")
398
+ except R2Error as e:
399
+ click.echo(f"Warning: Could not upload resolution record: {e}", err=True)
400
+
401
+ # Write active resolution marker so hooks can link the session_id
402
+ active_res_file = git_root / ".ch" / "active_resolution"
403
+ active_res_file.write_text(resolution_id, encoding="utf-8")
404
+
405
+ # 7. Write instruction file with paths to context files and process steps
406
+ instruction_file = Path(tempfile.mktemp(prefix="ch-resolve-instructions-", suffix=".md"))
407
+ instructions = _build_resolver_instructions(
408
+ resolution_id=resolution_id,
409
+ local_sha=local_full_sha,
410
+ remote_sha=remote_full_sha,
411
+ local_context_file=str(local_context_file),
412
+ remote_context_file=str(remote_context_file) if remote_context_file else None,
413
+ conflicted_files=conflicted,
414
+ owner=config.owner,
415
+ repo=config.repo,
416
+ )
417
+ instruction_file.write_text(instructions, encoding="utf-8")
418
+
419
+ click.echo(f"Instructions: {instruction_file}")
420
+ click.echo("Launching resolver agent...\n")
421
+
422
+ # 8. Launch claude with instruction file (non-interactive, streaming JSON)
423
+ initial_prompt = (
424
+ f"Read the file {instruction_file} — it contains instructions for resolving a merge conflict "
425
+ f"between local commit {local_full_sha[:8]} and remote commit {remote_full_sha[:8] if remote_full_sha else 'unknown'}. "
426
+ f"Follow the steps in order. The rebase is in progress and conflict markers are in the working tree."
427
+ )
428
+ os.execlp("claude", "claude", "-p", initial_prompt, "--output-format", "stream-json", "--dangerously-skip-permissions")
429
+
430
+
431
+ def _format_commit_context(record: dict, side: str) -> str:
432
+ """Format a commit context record as markdown for the agent to read."""
433
+ sha = record.get("git_commit_hash", "unknown")[:8]
434
+ message = record.get("commit_message", "")
435
+ files = record.get("files_changed", [])
436
+ goal = record.get("goal", "")
437
+ subgoal = record.get("subgoal", "")
438
+ transcript = record.get("raw_context", "")
439
+
440
+ parts = [
441
+ f"# {side.title()} Commit Context ({sha})\n",
442
+ f"\n**Commit:** {sha}\n",
443
+ f"**Message:** {message}\n",
444
+ f"**Files changed:** {', '.join(files)}\n",
445
+ ]
446
+
447
+ if goal:
448
+ parts.append(f"\n## Goal\n\n{goal}\n")
449
+
450
+ if subgoal:
451
+ parts.append(f"\n## Subgoal\n\n{subgoal}\n")
452
+
453
+ if transcript:
454
+ parts.append(f"\n## Session Transcript\n\n{transcript}\n")
455
+
456
+ return "".join(parts)
457
+
458
+
459
+ def _build_resolver_instructions(
460
+ resolution_id: str,
461
+ local_sha: str,
462
+ remote_sha: str,
463
+ local_context_file: str,
464
+ remote_context_file: str | None,
465
+ conflicted_files: list[str],
466
+ owner: str,
467
+ repo: str,
468
+ ) -> str:
469
+ """Build the instruction file that guides the resolver agent through the process."""
470
+ parts = [
471
+ "# Merge Conflict Resolution Instructions\n",
472
+ "\n**You are an autonomous resolver agent.** Follow the steps below IN ORDER.\n",
473
+ "As you complete each step, the UI will update to show your progress.\n",
474
+ f"\n## Resolution Info\n",
475
+ f"- **Resolution ID:** {resolution_id}\n",
476
+ f"- **Local commit:** {local_sha[:8]}\n",
477
+ f"- **Remote commit:** {remote_sha[:8] if remote_sha else 'unknown'}\n",
478
+ f"- **Repository:** {owner}/{repo}\n",
479
+ f"\n## Conflicted Files\n",
480
+ ]
481
+
482
+ for f in conflicted_files:
483
+ parts.append(f"- `{f}`\n")
484
+
485
+ parts.append("\n## Process\n\n")
486
+ parts.append("Follow these steps **in order**:\n\n")
487
+
488
+ step = 1
489
+
490
+ # Step 1: Read local context
491
+ parts.append(f"### Step {step}: Read Local Commit Context\n\n")
492
+ parts.append(f"Read the local commit context to understand what changes were made and why:\n")
493
+ parts.append(f"```\n{local_context_file}\n```\n\n")
494
+ step += 1
495
+
496
+ # Step 2: Read remote context (if available)
497
+ if remote_context_file:
498
+ parts.append(f"### Step {step}: Read Remote Commit Context\n\n")
499
+ parts.append(f"Read the remote commit context to understand the incoming changes:\n")
500
+ parts.append(f"```\n{remote_context_file}\n```\n\n")
501
+ step += 1
502
+
503
+ # Step 3: Search for related commits
504
+ parts.append(f"### Step {step}: Search for Related Commits\n\n")
505
+ parts.append("Search for semantically related commits that might provide additional context.\n")
506
+ parts.append("Based on what you learned from the local and remote contexts, construct a search query:\n\n")
507
+ parts.append("```bash\n")
508
+ parts.append(f'ch search "<your search query based on the conflict>"\n')
509
+ parts.append("```\n\n")
510
+ parts.append("If the search returns relevant results, you can fetch their full context using:\n")
511
+ parts.append("```bash\n")
512
+ parts.append('ch get-context <commit-sha>\n')
513
+ parts.append("```\n\n")
514
+ step += 1
515
+
516
+ # Step 4: Read conflicted files
517
+ parts.append(f"### Step {step}: Read Conflicted Files\n\n")
518
+ parts.append("Read each conflicted file to see the conflict markers:\n\n")
519
+ for f in conflicted_files:
520
+ parts.append(f"- `{f}`\n")
521
+ parts.append("\n")
522
+ step += 1
523
+
524
+ # Step 5: Analyze and propose plan
525
+ parts.append(f"### Step {step}: Analyze and Propose Resolution Plan\n\n")
526
+ parts.append("Based on your understanding of both sides, propose a resolution plan.\n\n")
527
+ parts.append("For each conflicted file, describe:\n")
528
+ parts.append("1. What the **local side** changed and why (based on its goal/subgoal)\n")
529
+ parts.append("2. What the **remote side** changed and why (based on its goal/subgoal)\n")
530
+ parts.append("3. Your **proposed resolution**: which parts to keep from each side, and why\n")
531
+ parts.append("4. Any **trade-offs or risks** in your proposed resolution\n\n")
532
+ parts.append("**Do NOT edit any files yet.** Present your plan for user review.\n\n")
533
+ parts.append(f"When ready for review, run:\n")
534
+ parts.append(f"```bash\nch review-resolution {resolution_id}\n```\n")
535
+
536
+ return "".join(parts)
537
+
538
+
539
+ # ---------- ch mark-resolved ----------
540
+
541
+
542
+ @cli.command("mark-resolved")
543
+ @click.argument("resolution_id")
544
+ def mark_resolved(resolution_id: str):
545
+ """Mark a resolution as completed after the rebase succeeds."""
546
+ git_root = _find_ch_root()
547
+ if git_root is None:
548
+ click.echo("Error: Not a ContextHub repo. Run 'ch init' first.", err=True)
549
+ sys.exit(1)
550
+
551
+ config_path = git_root / ".ch" / "config.json"
552
+ try:
553
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
554
+ config = RepoConfig.from_dict(config_data)
555
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
556
+ click.echo(f"Error: Invalid .ch/config.json: {e}", err=True)
557
+ sys.exit(1)
558
+
559
+ commit_hash = gitops.get_head_hash(cwd=git_root)
560
+
561
+ # Read session_id from marker file (written by stream hook)
562
+ active_res_file = git_root / ".ch" / "active_resolution"
563
+ session_id = None
564
+ if active_res_file.is_file():
565
+ lines = active_res_file.read_text(encoding="utf-8").strip().split("\n")
566
+ if len(lines) >= 2:
567
+ session_id = lines[1]
568
+
569
+ updates = {
570
+ "status": "completed",
571
+ "resolved_commit_hash": commit_hash,
572
+ "completed_at": datetime.now(timezone.utc).isoformat(),
573
+ }
574
+ if session_id:
575
+ updates["session_id"] = session_id
576
+
577
+ try:
578
+ update_resolution(config.owner, config.repo, resolution_id, updates)
579
+ click.echo(f"Resolution {resolution_id} marked completed (commit {commit_hash[:8]}).")
580
+ if session_id:
581
+ click.echo(f"Linked session: {session_id}")
582
+ except R2Error as e:
583
+ click.echo(f"Error: Could not update resolution record: {e}", err=True)
584
+ sys.exit(1)
585
+
586
+ # Don't delete active_resolution — handle_finalize needs it to link session_id
587
+
588
+
589
+ # ---------- ch review-resolution ----------
590
+
591
+
592
+ @cli.command("review-resolution")
593
+ @click.argument("resolution_id")
594
+ def review_resolution(resolution_id: str):
595
+ """Mark a resolution as pending review (called by resolver agent before asking user)."""
596
+ git_root = _find_ch_root()
597
+ if git_root is None:
598
+ click.echo("Error: Not a ContextHub repo. Run 'ch init' first.", err=True)
599
+ sys.exit(1)
600
+
601
+ config_path = git_root / ".ch" / "config.json"
602
+ try:
603
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
604
+ config = RepoConfig.from_dict(config_data)
605
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
606
+ click.echo(f"Error: Invalid .ch/config.json: {e}", err=True)
607
+ sys.exit(1)
608
+
609
+ # Fetch current record to increment review_count
610
+ try:
611
+ record = get_resolution(config.owner, config.repo, resolution_id)
612
+ except R2Error as e:
613
+ click.echo(f"Error: Could not fetch resolution record: {e}", err=True)
614
+ sys.exit(1)
615
+
616
+ review_count = record.get("review_count", 0) + 1
617
+
618
+ updates = {
619
+ "status": "pending_review",
620
+ "review_requested_at": datetime.now(timezone.utc).isoformat(),
621
+ "review_count": review_count,
622
+ }
623
+
624
+ try:
625
+ update_resolution(config.owner, config.repo, resolution_id, updates)
626
+ click.echo(f"Resolution {resolution_id} marked pending_review (review #{review_count}).")
627
+ except R2Error as e:
628
+ click.echo(f"Error: Could not update resolution record: {e}", err=True)
629
+ sys.exit(1)
630
+
631
+
632
+ # ---------- ch accept-resolution ----------
633
+
634
+
635
+ @cli.command("accept-resolution")
636
+ @click.argument("resolution_id")
637
+ def accept_resolution(resolution_id: str):
638
+ """Mark a resolution as accepted by the user."""
639
+ git_root = _find_ch_root()
640
+ if git_root is None:
641
+ click.echo("Error: Not a ContextHub repo. Run 'ch init' first.", err=True)
642
+ sys.exit(1)
643
+
644
+ config_path = git_root / ".ch" / "config.json"
645
+ try:
646
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
647
+ config = RepoConfig.from_dict(config_data)
648
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
649
+ click.echo(f"Error: Invalid .ch/config.json: {e}", err=True)
650
+ sys.exit(1)
651
+
652
+ updates = {
653
+ "status": "accepted",
654
+ "accepted_at": datetime.now(timezone.utc).isoformat(),
655
+ }
656
+
657
+ try:
658
+ update_resolution(config.owner, config.repo, resolution_id, updates)
659
+ click.echo(f"Resolution {resolution_id} accepted.")
660
+ except R2Error as e:
661
+ click.echo(f"Error: Could not update resolution record: {e}", err=True)
662
+ sys.exit(1)
663
+
664
+
665
+ # ---------- ch get-context ----------
666
+
667
+
668
+ @cli.command("get-context")
669
+ @click.argument("sha")
670
+ def get_context_cmd(sha: str):
671
+ """Fetch commit context from R2 and write to a temp file.
672
+
673
+ This command fetches the full context for a commit (goal, subgoal, transcript)
674
+ from R2 and writes it to a temp file that can be read by the agent.
675
+
676
+ \b
677
+ Examples:
678
+ ch get-context abc1234
679
+ ch get-context def5678
680
+ """
681
+ import tempfile
682
+
683
+ from contexthub.core.r2 import get_commit_context
684
+
685
+ # Find config
686
+ git_root = _find_ch_root()
687
+ if git_root is None:
688
+ click.echo("Error: Not a ContextHub repo. Run 'ch init' first.", err=True)
689
+ sys.exit(1)
690
+
691
+ config_path = git_root / ".ch" / "config.json"
692
+ try:
693
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
694
+ config = RepoConfig.from_dict(config_data)
695
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
696
+ click.echo(f"Error: Invalid .ch/config.json: {e}", err=True)
697
+ sys.exit(1)
698
+
699
+ # Fetch context from R2
700
+ click.echo(f"Fetching context for commit {sha}...")
701
+ try:
702
+ record = get_commit_context(config.owner, config.repo, sha)
703
+ except Exception as e:
704
+ click.echo(f"Error: Could not fetch context: {e}", err=True)
705
+ sys.exit(1)
706
+
707
+ full_sha = record.get("git_commit_hash", sha)
708
+
709
+ # Write to temp file
710
+ context_file = Path(tempfile.mktemp(prefix=f"ch-context-{full_sha[:8]}-", suffix=".md"))
711
+ context_file.write_text(_format_commit_context(record, "related"), encoding="utf-8")
712
+
713
+ click.echo(f"Context written to: {context_file}")
714
+
715
+
716
+ # ---------- ch search ----------
717
+
718
+
719
+ @cli.command()
720
+ @click.argument("query")
721
+ @click.option("--limit", default=10, type=int, help="Max results to return (default 10)")
722
+ @click.option("--owner", default=None, help="Repository owner (auto-detected from .ch/config.json)")
723
+ @click.option("--repo", default=None, help="Repository name (auto-detected from .ch/config.json)")
724
+ def search(query: str, limit: int, owner: str | None, repo: str | None):
725
+ """Semantic search over commit history.
726
+
727
+ \b
728
+ Examples:
729
+ ch search "authentication flow"
730
+ ch search "fix database connection" --limit 5
731
+ ch search "refactor" --owner myorg --repo myapp
732
+ """
733
+ # Auto-detect owner/repo from config if not provided
734
+ if not owner or not repo:
735
+ git_root = _find_ch_root()
736
+ if git_root is None:
737
+ click.echo(
738
+ "Error: Not a ContextHub repo and --owner/--repo not provided. "
739
+ "Run 'ch init' first or pass --owner and --repo.",
740
+ err=True,
741
+ )
742
+ sys.exit(1)
743
+ config_path = git_root / ".ch" / "config.json"
744
+ try:
745
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
746
+ config = RepoConfig.from_dict(config_data)
747
+ owner = owner or config.owner
748
+ repo = repo or config.repo
749
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
750
+ click.echo(f"Error: Invalid .ch/config.json: {e}", err=True)
751
+ sys.exit(1)
752
+
753
+ # Call the search API
754
+ results = _call_search_api(query, owner, repo, limit)
755
+ if results is None:
756
+ click.echo("Error: Could not reach search API.", err=True)
757
+ sys.exit(1)
758
+
759
+ if not results:
760
+ click.echo("No results found.")
761
+ return
762
+
763
+ click.echo(f'\nFound {len(results)} result{"s" if len(results) != 1 else ""} for "{query}"\n')
764
+ for r in results:
765
+ score = r.get("score", 0)
766
+ sha = r.get("sha", r.get("commit_hash", "???????"))[:7]
767
+ message = r.get("message", r.get("commit_message", ""))
768
+ summary = r.get("summary", r.get("semantic_summary", ""))
769
+
770
+ pct = f"{score * 100:.0f}%"
771
+ click.echo(f" {pct:>4} \u2502 {sha} \u2502 {message}")
772
+ if summary:
773
+ truncated = (summary[:72] + "...") if len(summary) > 75 else summary
774
+ click.echo(f" \u2502 \u2502 {truncated}")
775
+
776
+
777
+ # ---------- ch commit ----------
778
+
779
+
780
+ @cli.command()
781
+ @click.option("-m", "--message", required=True, help="Commit message")
782
+ @click.option("--context", "context_file", default=None, help="File containing context")
783
+ @click.option("--session", is_flag=True, help="Capture context from latest Claude Code session")
784
+ @click.option("--stdin", "use_stdin", is_flag=True, help="Read context from stdin")
785
+ @click.option("--no-push", is_flag=True, help="Skip pushing to remote")
786
+ @click.option("--goal", default=None, help="High-level objective this commit works towards")
787
+ @click.option("--subgoal", default=None, help="Specific task: problem, requirements, implementation")
788
+ @click.option("--resolves", "resolution_id", default=None, help="Resolution ID to mark as completed")
789
+ def commit(message: str, context_file: str | None, session: bool, use_stdin: bool, no_push: bool, goal: str | None, subgoal: str | None, resolution_id: str | None):
790
+ """Commit changes with agent context."""
791
+ # 1. Find .ch/ root
792
+ git_root = _find_ch_root()
793
+ if git_root is None:
794
+ click.echo("Error: Not a ContextHub repo. Run 'ch init' first.", err=True)
795
+ sys.exit(1)
796
+
797
+ # 2. Read config
798
+ config_path = git_root / ".ch" / "config.json"
799
+ try:
800
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
801
+ config = RepoConfig.from_dict(config_data)
802
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
803
+ click.echo(f"Error: Invalid .ch/config.json: {e}", err=True)
804
+ sys.exit(1)
805
+
806
+ # 3. Capture context
807
+ explicit_context = session or context_file is not None or use_stdin
808
+ session_path = None
809
+ try:
810
+ if explicit_context:
811
+ raw_context, context_source, session_id = capture_context(
812
+ session=session,
813
+ context_file=context_file,
814
+ stdin=use_stdin,
815
+ git_root=git_root,
816
+ )
817
+ else:
818
+ # Auto-detect: if streaming hooks are active, reference the session
819
+ # without embedding the transcript (it's already in R2 via hooks)
820
+ result = find_latest_session(git_root)
821
+ if result is not None:
822
+ session_path, session_id = result
823
+ raw_context = None
824
+ context_source = "streamed_session"
825
+ else:
826
+ session_path = None
827
+ raw_context, context_source, session_id = None, None, None
828
+ except ContextError as e:
829
+ click.echo(f"Error: {e}", err=True)
830
+ sys.exit(1)
831
+
832
+ if raw_context is None and context_source is None:
833
+ click.echo("Warning: No context provided. Committing without context.", err=True)
834
+
835
+ # 4. Stage all changes
836
+ gitops.add_all(cwd=git_root)
837
+
838
+ # 5. Check there's something to commit
839
+ if not gitops.has_staged_changes(cwd=git_root):
840
+ click.echo("Error: Nothing to commit.", err=True)
841
+ sys.exit(1)
842
+
843
+ # 6. Create git commit
844
+ try:
845
+ gitops.commit(message, cwd=git_root)
846
+ except GitError as e:
847
+ click.echo(f"Error: Git commit failed: {e}", err=True)
848
+ sys.exit(1)
849
+
850
+ # 7. Get commit info
851
+ commit_hash = gitops.get_head_hash(cwd=git_root)
852
+ branch = gitops.get_current_branch(cwd=git_root)
853
+ files_changed = gitops.get_changed_files(commit_hash, cwd=git_root)
854
+
855
+ # 8. Build context record — embed transcript if available
856
+ transcript = None
857
+ if context_source == "streamed_session" and session_path and session_path.is_file():
858
+ try:
859
+ transcript = parse_session_jsonl(session_path)
860
+ except Exception:
861
+ pass
862
+
863
+ record = ContextRecord(
864
+ id=uuid.uuid4().hex[:12],
865
+ git_commit_hash=commit_hash,
866
+ commit_message=message,
867
+ files_changed=files_changed,
868
+ timestamp=datetime.now(timezone.utc).isoformat(),
869
+ raw_context=raw_context or transcript,
870
+ context_source=context_source,
871
+ session_id=session_id,
872
+ branch=branch,
873
+ goal=goal,
874
+ subgoal=subgoal,
875
+ )
876
+
877
+ try:
878
+ upload_context(config.owner, config.repo, commit_hash, record.to_dict())
879
+ except R2Error as e:
880
+ click.echo(
881
+ f"Error: {e}. Git commit succeeded ({commit_hash[:8]}).",
882
+ err=True,
883
+ )
884
+ # Don't exit — git commit is preserved
885
+ if not no_push:
886
+ _do_push(git_root, config, commit_hash)
887
+ _print_summary(commit_hash, branch, files_changed, raw_context or transcript, context_source, context_uploaded=False)
888
+ sys.exit(1)
889
+
890
+ # 10. If this commit resolves a merge conflict, update the resolution record
891
+ if resolution_id:
892
+ try:
893
+ update_resolution(config.owner, config.repo, resolution_id, {
894
+ "status": "completed",
895
+ "resolved_commit_hash": commit_hash,
896
+ "completed_at": datetime.now(timezone.utc).isoformat(),
897
+ })
898
+ click.echo(f"Resolution {resolution_id} marked completed.")
899
+ except R2Error as e:
900
+ click.echo(f"Warning: Could not update resolution record: {e}", err=True)
901
+
902
+ # 11. Push to remote (always, unless --no-push)
903
+ if not no_push:
904
+ _do_push(git_root, config, commit_hash)
905
+
906
+ # 12. Print summary
907
+ _print_summary(commit_hash, branch, files_changed, raw_context or transcript, context_source, context_uploaded=True)
908
+
909
+
910
+ def _install_hooks(git_root: Path) -> None:
911
+ """Install Claude Code hooks into .claude/settings.json."""
912
+ claude_dir = git_root / ".claude"
913
+ claude_dir.mkdir(parents=True, exist_ok=True)
914
+ settings_path = claude_dir / "settings.json"
915
+
916
+ if settings_path.is_file():
917
+ try:
918
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
919
+ except (json.JSONDecodeError, ValueError):
920
+ settings = {}
921
+ else:
922
+ settings = {}
923
+
924
+ hooks = settings.setdefault("hooks", {})
925
+
926
+ ch_stream_cmd = "ch _hook stream"
927
+ ch_finalize_cmd = "ch _hook finalize"
928
+
929
+ ch_stop_entry = {
930
+ "hooks": [{"type": "command", "command": ch_stream_cmd, "async": True}],
931
+ }
932
+ ch_end_entry = {
933
+ "hooks": [{"type": "command", "command": ch_finalize_cmd, "async": True}],
934
+ }
935
+
936
+ # Merge into existing hooks without duplicating
937
+ def _has_command(entries: list, cmd: str) -> bool:
938
+ for entry in entries:
939
+ for h in entry.get("hooks", []):
940
+ if h.get("command") == cmd:
941
+ return True
942
+ return False
943
+
944
+ stop_hooks = hooks.get("Stop", [])
945
+ if not _has_command(stop_hooks, ch_stream_cmd):
946
+ stop_hooks.append(ch_stop_entry)
947
+ hooks["Stop"] = stop_hooks
948
+
949
+ end_hooks = hooks.get("SessionEnd", [])
950
+ if not _has_command(end_hooks, ch_finalize_cmd):
951
+ end_hooks.append(ch_end_entry)
952
+ hooks["SessionEnd"] = end_hooks
953
+
954
+ settings["hooks"] = hooks
955
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
956
+ click.echo(" Hooks: Stop -> ch _hook stream")
957
+ click.echo(" Hooks: SessionEnd -> ch _hook finalize")
958
+
959
+
960
+ _CLAUDE_MD_SECTION = """\
961
+
962
+ ## ContextHub
963
+
964
+ **Never use `git commit` or `git push` directly. Always use `ch commit`.**
965
+
966
+ When you are done with your changes and ready to commit, ask the user:
967
+ > "Ready to commit these changes? I'll use `ch commit` to save the context."
968
+
969
+ Then run:
970
+ ```bash
971
+ ch commit -m "concise description" --goal "<goal>" --subgoal "<subgoal>"
972
+ ```
973
+
974
+ **Always include both `--goal` and `--subgoal`.** Write them as a software engineer scoping work — be detailed and specific, not terse. **Format both as markdown** so they render well when displayed later.
975
+
976
+ #### `--goal` (the high-level objective)
977
+
978
+ Describe the broader objective this commit contributes to as a short markdown paragraph. Include enough context that someone unfamiliar with the project can understand the purpose. Cover: what feature or system is being built, why it matters, what the end state looks like, and how this commit fits into the bigger picture.
979
+
980
+ Example:
981
+ ```
982
+ --goal "Building a complete **user authentication system** with OAuth2, session management, and role-based access control. The system needs to support enterprise SSO providers, handle token refresh flows, and integrate with the existing user database. This is the foundational auth layer that all API endpoints will depend on for access control."
983
+ ```
984
+
985
+ #### `--subgoal` (the specific task in this commit)
986
+
987
+ Describe exactly what this commit accomplishes. **Use markdown headers and bullet points** to structure the subgoal into sections: `## Problem`, `## Requirements`, `## Implementation`, `## Trade-offs`, and `## TODO`.
988
+
989
+ Example:
990
+ ```
991
+ --subgoal "## Problem
992
+ The OAuth2 implementation was using the implicit grant flow which is deprecated and insecure for server-side apps — tokens were exposed in URL fragments.
993
+
994
+ ## Requirements
995
+ - Must use Authorization Code flow with PKCE
996
+ - Must handle token refresh without user interaction
997
+ - Must store refresh tokens encrypted at rest
998
+
999
+ ## Implementation
1000
+ - Replaced the implicit flow in `auth_middleware.py` with Authorization Code + PKCE using a new `PKCEChallenge` class
1001
+ - Added `token_store.py` with AES-256 encrypted storage for refresh tokens
1002
+ - Updated the `/callback` endpoint to exchange codes for tokens server-side
1003
+
1004
+ ## Trade-offs
1005
+ Chose to store refresh tokens in the database rather than Redis for durability over speed — auth token refresh is infrequent enough that the latency is acceptable.
1006
+
1007
+ ## TODO
1008
+ - Token revocation endpoint
1009
+ - Refresh token rotation"
1010
+ ```
1011
+
1012
+ This stages, commits, uploads context to R2, and pushes — all in one step.
1013
+
1014
+ ### Merge Conflicts
1015
+
1016
+ If `ch commit` detects a merge conflict during push, it will:
1017
+ 1. Abort the rebase to keep the working tree clean
1018
+ 2. Print a `ch resolve <local-sha> <remote-sha>` command
1019
+
1020
+ **When you see this, run the printed `ch resolve` command.** It will spawn a resolver agent with full context from both sides of the conflict.
1021
+
1022
+ ### Spawning Agents with Context
1023
+
1024
+ To continue work from a previous commit or understand its reasoning:
1025
+ ```bash
1026
+ ch spawn <commit-sha>
1027
+ ```
1028
+ """
1029
+
1030
+
1031
+ def _install_claude_md(git_root: Path) -> None:
1032
+ """Add ContextHub instructions to CLAUDE.md in the repo root."""
1033
+ claude_md = git_root / "CLAUDE.md"
1034
+ marker = "## ContextHub"
1035
+
1036
+ if claude_md.is_file():
1037
+ content = claude_md.read_text(encoding="utf-8")
1038
+ if marker in content:
1039
+ click.echo(" CLAUDE.md already has ContextHub section.")
1040
+ return
1041
+ # Append to existing file
1042
+ with open(claude_md, "a", encoding="utf-8") as f:
1043
+ if not content.endswith("\n"):
1044
+ f.write("\n")
1045
+ f.write(_CLAUDE_MD_SECTION)
1046
+ click.echo(" Appended ContextHub section to CLAUDE.md")
1047
+ else:
1048
+ claude_md.write_text(f"# Project Instructions\n{_CLAUDE_MD_SECTION}", encoding="utf-8")
1049
+ click.echo(" Created CLAUDE.md with ContextHub instructions.")
1050
+
1051
+
1052
+ def _find_ch_root() -> Path | None:
1053
+ """Walk up from cwd to find a directory containing .ch/."""
1054
+ current = Path.cwd().resolve()
1055
+ while True:
1056
+ if (current / ".ch").is_dir():
1057
+ return current
1058
+ parent = current.parent
1059
+ if parent == current:
1060
+ return None
1061
+ current = parent
1062
+
1063
+
1064
+ def _do_push(git_root: Path, config: RepoConfig | None = None, local_sha: str | None = None):
1065
+ """Push to remote. On conflict, attempt rebase and offer to spawn resolver."""
1066
+ try:
1067
+ gitops.push(cwd=git_root)
1068
+ click.echo("Pushed to remote.")
1069
+ return
1070
+ except GitError:
1071
+ pass # push failed, try to reconcile
1072
+
1073
+ # Push failed — fetch and try rebase
1074
+ click.echo("Push failed — fetching remote changes...")
1075
+ try:
1076
+ gitops.fetch(cwd=git_root)
1077
+ except GitError as e:
1078
+ click.echo(f"Warning: Fetch failed: {e}", err=True)
1079
+ return
1080
+
1081
+ # Get the incoming remote commits before rebase (for context lookup)
1082
+ remote_shas = gitops.get_upstream_commits(cwd=git_root)
1083
+
1084
+ click.echo("Attempting rebase...")
1085
+ try:
1086
+ gitops.pull_rebase(cwd=git_root)
1087
+ # Clean rebase — push again
1088
+ click.echo("Rebase succeeded. Pushing...")
1089
+ try:
1090
+ gitops.push(cwd=git_root)
1091
+ click.echo("Pushed to remote.")
1092
+ except GitError as e:
1093
+ click.echo(f"Warning: Push after rebase failed: {e}", err=True)
1094
+ return
1095
+ except GitError as e:
1096
+ if "rebase_conflict" not in str(e):
1097
+ click.echo(f"Warning: Rebase failed: {e}", err=True)
1098
+ return
1099
+
1100
+ # Conflict detected
1101
+ conflicted = gitops.get_conflicted_files(cwd=git_root)
1102
+ click.echo(f"\nMerge conflict detected in {len(conflicted)} file(s):")
1103
+ for f in conflicted:
1104
+ click.echo(f" {f}")
1105
+
1106
+ # Abort the rebase so the working tree is clean for the resolver
1107
+ click.echo("Aborting rebase (resolver agent will handle it)...")
1108
+ try:
1109
+ gitops.abort_rebase(cwd=git_root)
1110
+ except GitError:
1111
+ pass
1112
+
1113
+ # Create resolution record in R2
1114
+ if not config or not local_sha:
1115
+ click.echo("Run 'ch resolve <local-sha> <remote-sha>' to resolve with context.", err=True)
1116
+ return
1117
+
1118
+ # Snapshot conflicted files at the local SHA (before it's pushed to GitHub)
1119
+ file_snapshots: dict[str, str] = {}
1120
+ for f in conflicted:
1121
+ try:
1122
+ file_snapshots[f] = gitops.show_file(local_sha, f, cwd=git_root)
1123
+ except GitError:
1124
+ pass
1125
+
1126
+ remote_sha = remote_shas[0] if remote_shas else gitops.get_remote_head(cwd=git_root)
1127
+ resolution_id = uuid.uuid4().hex[:12]
1128
+ resolution = ResolutionRecord(
1129
+ id=resolution_id,
1130
+ owner=config.owner,
1131
+ repo=config.repo,
1132
+ local_sha=local_sha,
1133
+ remote_sha=remote_sha or "",
1134
+ session_id=None,
1135
+ status="pending",
1136
+ started_at=datetime.now(timezone.utc).isoformat(),
1137
+ conflicted_files=conflicted,
1138
+ repo_path=str(git_root),
1139
+ file_snapshots=file_snapshots,
1140
+ )
1141
+ try:
1142
+ upload_resolution(config.owner, config.repo, resolution_id, resolution.to_dict())
1143
+ click.echo(f"\nResolution record created: {resolution_id}")
1144
+ except R2Error as e:
1145
+ click.echo(f"Warning: Could not upload resolution record: {e}", err=True)
1146
+
1147
+ click.echo(f"\nLocal commit: {local_sha[:8]}")
1148
+ if remote_sha:
1149
+ click.echo(f"Remote commit: {remote_sha[:8]}")
1150
+ click.echo(f"\nOpen the UI to resolve:")
1151
+ click.echo(f" http://localhost:3000/merge/{resolution_id}?owner={config.owner}&repo={config.repo}")
1152
+
1153
+
1154
+ def _print_summary(
1155
+ commit_hash: str,
1156
+ branch: str | None,
1157
+ files_changed: list[str],
1158
+ raw_context: str | None,
1159
+ context_source: str | None,
1160
+ context_uploaded: bool,
1161
+ ):
1162
+ """Print commit summary."""
1163
+ click.echo(f"\nCommit: {commit_hash[:8]}")
1164
+ if branch:
1165
+ click.echo(f"Branch: {branch}")
1166
+ click.echo(f"Files: {len(files_changed)} changed")
1167
+ for f in files_changed:
1168
+ click.echo(f" {f}")
1169
+ if raw_context:
1170
+ size_kb = len(raw_context.encode("utf-8")) / 1024
1171
+ click.echo(f"Context: {size_kb:.1f}KB {'uploaded' if context_uploaded else 'NOT uploaded'}")
1172
+ elif context_source == "streamed_session":
1173
+ click.echo(f"Context: streamed session {'(commit record uploaded)' if context_uploaded else '(commit record NOT uploaded)'}")
1174
+ else:
1175
+ click.echo("Context: none")
1176
+
1177
+
1178
+ # ---------- search helpers ----------
1179
+
1180
+
1181
+ def _call_search_api(query: str, owner: str, repo: str, limit: int = 10, timeout: int = 30) -> list[dict] | None:
1182
+ """Call the semantic search API. Returns list of results, or None on failure."""
1183
+ payload = json.dumps({"query": query, "owner": owner, "repo": repo, "limit": limit}).encode("utf-8")
1184
+ req = urllib.request.Request(
1185
+ "http://localhost:3000/api/vectorize/search",
1186
+ data=payload,
1187
+ headers={"Content-Type": "application/json"},
1188
+ method="POST",
1189
+ )
1190
+ try:
1191
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
1192
+ data = json.loads(resp.read().decode("utf-8"))
1193
+ return data.get("results", [])
1194
+ except Exception:
1195
+ return None
1196
+
1197
+
1198
+ # ---------- resolve helpers ----------
1199
+
1200
+
1201
+ def _build_resolve_search_query(local_record: dict, remote_record: dict | None) -> str:
1202
+ """Build a search query from both commits' messages, goals, and subgoals."""
1203
+ parts: list[str] = []
1204
+
1205
+ # Commit messages first (most concise signal)
1206
+ local_msg = local_record.get("commit_message", "")
1207
+ if local_msg:
1208
+ parts.append(local_msg)
1209
+ if remote_record:
1210
+ remote_msg = remote_record.get("commit_message", "")
1211
+ if remote_msg:
1212
+ parts.append(remote_msg)
1213
+
1214
+ # Goals next (broader context)
1215
+ local_goal = local_record.get("goal", "")
1216
+ if local_goal:
1217
+ parts.append(local_goal)
1218
+ if remote_record:
1219
+ remote_goal = remote_record.get("goal", "")
1220
+ if remote_goal:
1221
+ parts.append(remote_goal)
1222
+
1223
+ # Subgoals last (truncated — they're long markdown)
1224
+ local_subgoal = local_record.get("subgoal", "")
1225
+ if local_subgoal:
1226
+ parts.append(local_subgoal[:200])
1227
+ if remote_record:
1228
+ remote_subgoal = remote_record.get("subgoal", "")
1229
+ if remote_subgoal:
1230
+ parts.append(remote_subgoal[:200])
1231
+
1232
+ query = " ".join(parts)
1233
+ # Truncate to ~500 chars total
1234
+ return query[:500]
1235
+
1236
+
1237
+ def _search_related_commits(
1238
+ query: str,
1239
+ owner: str,
1240
+ repo: str,
1241
+ exclude_shas: list[str],
1242
+ ) -> list[dict]:
1243
+ """Search for semantically related commits and fetch their full R2 records.
1244
+
1245
+ Returns list of dicts with keys: record, score, sha.
1246
+ Returns [] on any failure — fully graceful.
1247
+ """
1248
+ from contexthub.core.r2 import get_commit_context
1249
+
1250
+ if not query.strip():
1251
+ return []
1252
+
1253
+ results = _call_search_api(query, owner, repo, limit=10)
1254
+ if not results:
1255
+ click.echo("No related commits found (or search unavailable).")
1256
+ return []
1257
+
1258
+ # Filter: score >= 0.65, exclude local/remote SHAs, cap at 5
1259
+ exclude_set = {s for s in exclude_shas if s}
1260
+ filtered = []
1261
+ for r in results:
1262
+ score = r.get("score", 0)
1263
+ sha = r.get("sha", r.get("commit_hash", ""))
1264
+ if score < 0.65:
1265
+ continue
1266
+ if sha in exclude_set or sha[:7] in {s[:7] for s in exclude_set}:
1267
+ continue
1268
+ filtered.append((sha, score))
1269
+ if len(filtered) >= 5:
1270
+ break
1271
+
1272
+ if not filtered:
1273
+ click.echo("No related commits found (or search unavailable).")
1274
+ return []
1275
+
1276
+ # Fetch full records from R2
1277
+ related: list[dict] = []
1278
+ for sha, score in filtered:
1279
+ try:
1280
+ record = get_commit_context(owner, repo, sha)
1281
+ related.append({"record": record, "score": score, "sha": sha})
1282
+ except Exception:
1283
+ continue # skip this commit, others still included
1284
+
1285
+ if related:
1286
+ click.echo(f"Found {len(related)} related commit(s) for additional context.")
1287
+ else:
1288
+ click.echo("No related commits found (or search unavailable).")
1289
+
1290
+ return related
1291
+
1292
+
1293
+ # ---------- ch _hook (hidden) ----------
1294
+
1295
+
1296
+ @cli.group("_hook", hidden=True)
1297
+ def hook_group():
1298
+ """Internal hook handlers (called by Claude Code hooks)."""
1299
+ pass
1300
+
1301
+
1302
+ @hook_group.command("stream")
1303
+ def hook_stream():
1304
+ """Handle a Stop event — upload transcript snapshot to R2."""
1305
+ try:
1306
+ raw = sys.stdin.read()
1307
+ hook_input = json.loads(raw)
1308
+ except (json.JSONDecodeError, ValueError):
1309
+ return
1310
+
1311
+ try:
1312
+ from contexthub.hooks import handle_stream
1313
+ handle_stream(hook_input)
1314
+ except Exception:
1315
+ pass # hooks must never produce output or crash
1316
+
1317
+
1318
+ @hook_group.command("finalize")
1319
+ def hook_finalize():
1320
+ """Handle a SessionEnd event — upload final transcript + meta to R2."""
1321
+ try:
1322
+ raw = sys.stdin.read()
1323
+ hook_input = json.loads(raw)
1324
+ except (json.JSONDecodeError, ValueError):
1325
+ return
1326
+
1327
+ try:
1328
+ from contexthub.hooks import handle_finalize
1329
+ handle_finalize(hook_input)
1330
+ except Exception:
1331
+ pass # hooks must never produce output or crash