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.
- package/bin/ch +25 -0
- package/contexthub/__init__.py +3 -0
- package/contexthub/cli.py +1331 -0
- package/contexthub/core/__init__.py +0 -0
- package/contexthub/core/context.py +207 -0
- package/contexthub/core/git.py +173 -0
- package/contexthub/core/github.py +35 -0
- package/contexthub/core/models.py +81 -0
- package/contexthub/core/r2.py +221 -0
- package/contexthub/hooks.py +154 -0
- package/install.sh +44 -0
- package/package.json +22 -0
- package/requirements.txt +2 -0
|
@@ -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
|