git-sci 0.0.1__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
git_sci-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-sci
3
+ Version: 0.1.1
4
+ Summary: Share your team's memory - you, your agents, and your teammate's agents.
5
+ Requires-Python: >=3.9
@@ -0,0 +1,848 @@
1
+ #!/usr/bin/env python3
2
+ """git-sci: an operation log for humans + agents on top of git.
3
+
4
+ Commits are attributed as <user>/<source>: <user>/local, or <user>/agent:<harness>/<session>.
5
+ PreToolUse commits dirty state as local; PostToolUse commits the pre->post diff as the agent's;
6
+ Stop squashes the turn (per-call commits stay under refs/git-sci/ops) and appends the turn's
7
+ transcript chunk to refs/git-sci/sessions/<user>/<id>; init imports existing sessions + backfills.
8
+ Events: .git/git-sci/events.jsonl (cache) mirrored to refs/git-sci/log/<user>, one writer each, merged
9
+ by timestamp at read; refs/git-sci/* are the source of truth and travel via the refspec + pre-push
10
+ hook init installs. .git/git-sci/lock (flock) serializes everything, like git's index.lock."""
11
+ import argparse, difflib, fcntl, glob, hashlib, json, os, re, subprocess, sys
12
+ import time, uuid, zlib
13
+ from datetime import datetime
14
+ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"}
15
+ HOOK_EVENTS = ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop")
16
+ CODEX_HOOK_EVENTS = HOOK_EVENTS + ("SubagentStart", "SubagentStop")
17
+ CLAUDE_LINE = ("Past sessions for this repo, yours and other team members', are in "
18
+ ".git/git-sci/sessions/ (same layout as ~/.claude/projects/, with a "
19
+ "sessions-index.json). Look there instead of ~/.claude/projects/.")
20
+ CODEX_LINE = ("Past sessions for this repo, yours and other team members', are in "
21
+ ".git/git-sci/sessions/ (with a sessions-index.json). Look there instead of "
22
+ "~/.codex/sessions/, or use `git-sci sessions` to search them.")
23
+ projects = lambda: os.environ.get("GIT_SCI_PROJECTS", os.path.expanduser("~/.claude/projects"))
24
+ codex_sessions = lambda: os.environ.get(
25
+ "GIT_SCI_CODEX_SESSIONS", os.path.expanduser("~/.codex/sessions"))
26
+ def sh(*a, input=None, check=True, env=None):
27
+ p = subprocess.run(a, capture_output=True, text=True, input=input, env=env)
28
+ if check and p.returncode: sys.exit(f"git-sci: `{' '.join(a)}` failed: {p.stderr.strip()}")
29
+ return p.stdout
30
+ git = lambda *a, **k: sh("git", *a, **k)
31
+ _r = {}
32
+ def root(): # memoized per cwd: called on every event, and hooks chdir before using it
33
+ return _r.get(os.getcwd()) or _r.setdefault(os.getcwd(), git("rev-parse", "--show-toplevel").strip())
34
+ head = lambda: git("rev-parse", "HEAD").strip()
35
+ quiet = lambda *a, **k: subprocess.run(a, capture_output=True, text=True, **k)
36
+ patches = lambda pre, post, files: {f: git("diff", pre, post, "--", f) for f in files}
37
+ def changed_files():
38
+ # status.renames=false lists a rename as D+A so commit --only keeps the deletion
39
+ return [l[3:].strip('"') for l in
40
+ git("-c", "core.quotepath=false", "-c", "status.renames=false",
41
+ "status", "--porcelain", "-uall").splitlines()]
42
+ def commit_files(files, message, author):
43
+ git("add", "-A", "--", *files, check=False) # pathspec may legitimately match nothing
44
+ c = ("-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "--allow-empty",
45
+ "--author", f"{author} <git-sci@git-sci>", "-m", message)
46
+ # --only keeps unrelated pre-staged paths out; empty repo matches no pathspec -> plain commit
47
+ quiet("git", *c, "--only", "--", *files).returncode and git(*c)
48
+ return head()
49
+ patch_id = lambda c: (git("patch-id", "--stable", check=False,
50
+ input=git("show", "--format=", c, check=False)).split() or [None])[0]
51
+ def blob(data): # loose object via hashlib+zlib: no subprocess per chunk (sha1 repos)
52
+ b = data.encode() if isinstance(data, str) else data
53
+ hdr = b"blob %d\x00" % len(b); sha = hashlib.sha1(hdr + b).hexdigest()
54
+ p = os.path.join(root(), ".git", "objects", sha[:2], sha[2:])
55
+ if not os.path.exists(p):
56
+ os.makedirs(os.path.dirname(p), exist_ok=True)
57
+ open(p, "wb").write(zlib.compress(hdr + b))
58
+ return sha
59
+ refs = lambda prefix: [l.split("\t") for l in git( # [(refname, sha)]
60
+ "for-each-ref", "--format=%(refname)\t%(objectname)", prefix, check=False).splitlines()]
61
+ def git_sci_dir(must=True): # .git/git-sci is a per-machine cache; refs/git-sci/* are the source of truth
62
+ d = os.path.join(root(), ".git", "git-sci")
63
+ if must and not os.path.isdir(d): sys.exit("git-sci: not initialized here (run `git-sci init`)")
64
+ return d
65
+ def jread(path, default):
66
+ try: return json.load(open(path))
67
+ except (OSError, ValueError): return default
68
+ jwrite = lambda path, obj: json.dump(obj, open(path, "w"), indent=1)
69
+ state = lambda name, default: jread(os.path.join(git_sci_dir(), name), default)
70
+ save_state = lambda name, obj: jwrite(os.path.join(git_sci_dir(), name), obj)
71
+ def parse_events(text): # skips torn lines rather than bricking every command
72
+ out = []
73
+ for l in text.splitlines():
74
+ try: out.append(json.loads(l))
75
+ except ValueError: pass
76
+ return out
77
+ def events(): # own cache first (file order), then teammates' logs; stable-sorted
78
+ try: evs = parse_events(open(os.path.join(git_sci_dir(), "events.jsonl")).read())
79
+ except OSError: evs = []
80
+ for ref, sha in refs("refs/git-sci/log/"):
81
+ if ref.split("/")[-1] != user():
82
+ evs += parse_events(git("cat-file", "blob", sha, check=False))
83
+ return sorted(evs, key=lambda e: e.get("timestamp", ""))
84
+ def lock():
85
+ lk = open(os.path.join(git_sci_dir(False), "lock"), "a")
86
+ fcntl.flock(lk, fcntl.LOCK_EX)
87
+ return lk # held until process exit
88
+ def mirror_log(): # one writer per user: refs/git-sci/log/<user> mirrors the local cache
89
+ p = os.path.join(git_sci_dir(), "events.jsonl")
90
+ if os.path.exists(p): git("update-ref", f"refs/git-sci/log/{user()}", blob(open(p).read()))
91
+ def record(mirror=True, **ev):
92
+ ev = {"id": uuid.uuid4().hex[:8], "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **ev}
93
+ with open(os.path.join(git_sci_dir(), "events.jsonl"), "a") as f:
94
+ f.write(json.dumps(ev) + "\n")
95
+ return mirror and mirror_log() or ev
96
+ def store_chunks(sid, files, u=None): # {path: text} -> refs/git-sci/sessions/<u>/<sid> tree
97
+ if not files: return
98
+ ref, ix = f"refs/git-sci/sessions/{u or user()}/{sid}", os.path.join(git_sci_dir(), "tmpindex")
99
+ env = {**os.environ, "GIT_INDEX_FILE": ix}
100
+ if os.path.exists(ix): os.remove(ix)
101
+ if not quiet("git", "rev-parse", "-q", "--verify", ref).returncode:
102
+ git("read-tree", ref, env=env)
103
+ git("update-index", "--index-info", env=env,
104
+ input="".join(f"100644 blob {blob(c)}\t{p}\n" for p, c in files.items()))
105
+ git("update-ref", ref, git("commit-tree", git("write-tree", env=env).strip(), "-m", sid).strip())
106
+ _u = {}
107
+ def user(): # the git user on whose machine the change happened; owns every event
108
+ _u or _u.setdefault("u", (git("config", "user.name", check=False).strip()
109
+ or os.environ.get("USER", "unknown")).replace(" ", "-").lower())
110
+ return _u["u"]
111
+ local = lambda u=None: f"{u or user()}/local"
112
+ agent = lambda sid, harness="claude", u=None: f"{u or user()}/agent:{harness}/{sid[:8]}"
113
+ session_key = lambda sid, harness="claude": sid if harness == "claude" else f"{harness}/{sid}"
114
+ call_key = lambda sid, payload, harness="claude": \
115
+ f"{session_key(sid, harness)}/{payload.get('tool_use_id') or ''}"
116
+ tool_file = lambda inp: inp.get("file_path") or inp.get("notebook_path")
117
+ rel_to_root = lambda path: os.path.relpath(os.path.realpath(path), root())
118
+ def patch_files(text):
119
+ if not isinstance(text, str): return []
120
+ # Tool wrappers may carry the patch inside a JSON or JavaScript string.
121
+ text = text.replace("\\r\\n", "\n").replace("\\n", "\n")
122
+ paths = re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", text, re.M)
123
+ paths += re.findall(r"^\*\*\* Move to: (.+)$", text, re.M)
124
+ paths += [p for p in re.findall(r"^\+\+\+ (?:b/)?(.+)$", text, re.M)
125
+ if p != "/dev/null"]
126
+ cleaned = [p.strip().rstrip("\\\"'`,);") for p in paths]
127
+ return list(dict.fromkeys(p for p in cleaned if p and p != "/dev/null"))
128
+ def tool_files(tool, inp):
129
+ if not isinstance(inp, dict): return patch_files(inp)
130
+ f = tool_file(inp)
131
+ if f: return [f]
132
+ if tool == "apply_patch":
133
+ return patch_files(inp.get("command") or inp.get("patch") or inp.get("input") or "")
134
+ return []
135
+ def relative_tool_files(tool, inp):
136
+ return [rel_to_root(f) for f in tool_files(tool, inp)]
137
+ def char_edits(patch): # character-level ranges for paired -/+ lines of a unified patch
138
+ out, o, n, minus = [], 0, 0, []
139
+ for l in patch.splitlines():
140
+ if l.startswith("@@"):
141
+ nums = l.split("@@")[1].split()[:2]
142
+ if len(nums) < 2: break
143
+ (o, n), minus = tuple(int(x.lstrip("-+").split(",")[0]) for x in nums), []
144
+ elif l.startswith("-") and not l.startswith("---"):
145
+ minus.append((o, l[1:])); o += 1
146
+ elif l.startswith("+") and not l.startswith("+++"):
147
+ if minus:
148
+ ol, old = minus.pop(0)
149
+ out += [{"old_line": ol, "new_line": n, "old_range": [a, b], "new_range": [c, d],
150
+ "old_text": old[a:b], "new_text": l[1:][c:d]} for t, a, b, c, d in
151
+ difflib.SequenceMatcher(None, old, l[1:]).get_opcodes() if t != "equal"]
152
+ n += 1
153
+ elif not l.startswith("\\") and not l.startswith("diff "):
154
+ o, n, minus = o + 1, n + 1, []
155
+ return out
156
+ edits_msg = lambda actor, files: (f"git-sci: {actor} edits " + ", ".join(files[:3])
157
+ + (f" +{len(files) - 3} more" if len(files) > 3 else ""))
158
+ def sync(exclude=()): # commit unclaimed changes as <user>/local
159
+ exclude = set(exclude) | {f for s in state("active.json", {}).values() for f in s["files"]}
160
+ files = [f for f in changed_files() if f not in exclude]
161
+ if not files: return None
162
+ pre, actor = head(), local()
163
+ c = commit_files(files, edits_msg(actor, files), actor)
164
+ p = patches(pre, c, files)
165
+ return record(type="edit", actor=actor, commit=c, patch_id=patch_id(c),
166
+ edits=[{"file": f, "patch": p[f], "char_edits": char_edits(p[f]),
167
+ "attribution": {"method": "hook"}} for f in files])
168
+ def maybe_sync(exclude=()): # skipped while an agent tool call is in flight
169
+ state("active.json", {}) or sync(exclude)
170
+ def finish_call(sid, active, payload, harness="claude"):
171
+ # PostToolUse: the pre->post diff is the agent's tool call
172
+ st = (active.pop(call_key(sid, payload, harness), None) # or an id-less claim
173
+ or active.pop(call_key(sid, {}, harness), None) or {"files": []})
174
+ save_state("active.json", active)
175
+ others = {f for s in active.values() for f in s["files"]}
176
+ files = [f for f in changed_files() if f not in others]
177
+ if not files: return
178
+ tool, inp = payload.get("tool_name", "?"), payload.get("tool_input") or {}
179
+ paths = relative_tool_files(tool, inp)
180
+ call_input = ({"file": paths[0]} if len(paths) == 1 else
181
+ {"files": paths} if paths else inp)
182
+ call = {"id": payload.get("tool_use_id"), "tool": tool, "input": call_input}
183
+ claimed = set(st["files"]) | set(paths)
184
+ # Edit/Write only touch their own file -> the rest of the diff is a concurrent local
185
+ # overlay; for other tools (Bash, ...) the whole diff is the agent's
186
+ a_files = [f for f in files if f in claimed] if tool in EDIT_TOOLS and claimed else files
187
+ h_files = [f for f in files if f not in a_files]
188
+ actor, pre = agent(sid, harness), head()
189
+ turn = state("turns.json", {}).get(session_key(sid, harness), {}).get("n", 0)
190
+ c = commit_files(files, f"git-sci: {actor} {tool}" if a_files else edits_msg(local(), files),
191
+ actor if a_files else local())
192
+ p = patches(pre, c, files)
193
+ if a_files:
194
+ git("update-ref", f"refs/git-sci/ops/{c}", c) # survives the turn squash + git gc
195
+ record(type="edit", actor=actor, turn=f"turn{turn}", commit=c, patch_id=patch_id(c),
196
+ tool_calls=[call], edits=[{"file": f, "patch": p[f], "tool_call_id": call["id"],
197
+ "attribution": {"method": "hook"}} for f in a_files])
198
+ if h_files:
199
+ record(type="edit", actor=local(), commit=c,
200
+ edits=[{"file": f, "patch": p[f], "char_edits": char_edits(p[f]),
201
+ "attribution": {"method": "hook"}} for f in h_files])
202
+ def squash_turn(sid, harness="claude"):
203
+ # Stop: squash the turn's tool-call commits into one commit per turn;
204
+ # skipped when a foreign commit landed mid-turn, and per-call commits stay either way
205
+ t = state("turns.json", {}).get(session_key(sid, harness))
206
+ if not t or quiet("git", "merge-base", "--is-ancestor", t["start"], "HEAD").returncode: return
207
+ start, actor = t["start"], agent(sid, harness)
208
+ rng = git("rev-list", f"{start}..HEAD").split()
209
+ ops = [e for e in events() if e.get("commit") in rng]
210
+ mine = [e for e in ops if e["actor"] == actor and "squashes" not in e]
211
+ if not rng or set(rng) - {e["commit"] for e in mine}: return
212
+ # commit-tree + update-ref never touch the worktree/index: edits after the last call stay dirty
213
+ c = git("-c", f"user.name={actor}", "-c", "user.email=git-sci@git-sci", "commit-tree",
214
+ git("rev-parse", "HEAD^{tree}").strip(), "-p", start,
215
+ "-m", f"git-sci: {actor} turn {t['n']} ({len(rng)} tool calls)").strip()
216
+ git("update-ref", "HEAD", c, head())
217
+ files = git("diff", "--name-only", start, c).splitlines()
218
+ p = patches(start, c, files)
219
+ record(type="edit", actor=actor, turn=f"turn{t['n']}", commit=c, patch_id=patch_id(c),
220
+ squashes=[e["id"] for e in mine], tool_calls=[tc for e in mine for tc in e["tool_calls"]],
221
+ edits=[{"file": f, "patch": p[f], "attribution": {"method": "squash"}} for f in files])
222
+ for e in ops: # local overlays inside the agent's commits follow the squash
223
+ if e not in mine:
224
+ record(type=e["type"], actor=e["actor"], commit=c, edits=e["edits"], overlay_of=e["id"])
225
+ def prompt_line(l, harness="claude"):
226
+ try: obj = json.loads(l) # both harnesses write one JSON record per line
227
+ except ValueError: return False
228
+ if harness == "claude": # a user record of only tool_results is not a prompt, but a
229
+ content = (obj.get("message") or {}).get("content") # queued message may ride
230
+ return obj.get("type") == "user" and not (isinstance(content, list) and all(
231
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content))
232
+ p = obj.get("payload") or {}
233
+ if obj.get("type") == "event_msg":
234
+ return {"user_message": 1, "task_started": 3}.get(p.get("type"), 0)
235
+ return 2 * (obj.get("type") == "response_item" and p.get("type") == "message"
236
+ and p.get("role") == "user")
237
+ def prompt_boundary(sid, payload, harness="claude"):
238
+ # PreToolUse: a prompt can reach the transcript with no hook between turns (queued
239
+ # message, missed Stop); a user line past the first scan means a new turn started
240
+ turns = state("turns.json", {}); t = turns.get(session_key(sid, harness))
241
+ tp = payload.get("transcript_path") or (t or {}).get("transcript", "")
242
+ if not (t and tp and os.path.exists(tp)): return
243
+ f = open(tp, "rb"); prompts = []
244
+ f.seek(t.get("soffset", t.get("toffset", 0)))
245
+ while True:
246
+ pos, l = f.tell(), f.readline()
247
+ if not l: break
248
+ k = prompt_line(l, harness)
249
+ if k: prompts.append((pos, k))
250
+ end = f.tell(); kinds = {k for _, k in prompts}
251
+ # codex mirrors one prompt across records: prefer task_started (the earliest, as
252
+ # scan_codex_transcript does), else count just one of the user-record kinds
253
+ if 3 in kinds: prompts = [x for x in prompts if x[1] == 3]
254
+ elif {1, 2} <= kinds: prompts = [x for x in prompts if x[1] == 1]
255
+ seen = t.get("seen", 0) # the turn's own prompt is the first record seen for it,
256
+ bs = prompts[0 if seen else 1:] # however late the harness flushes it; the rest
257
+ for boundary, _ in bs: # are queued prompts, each starting a new turn
258
+ squash_turn(sid, harness) # close the previous turn before this turn's first commit
259
+ f.seek(t.get("toffset", 0)); data = f.read(boundary - f.tell())
260
+ if data and not t.get("stopped") and cfg_track(): # store its transcript slice too
261
+ store_chunks(sid, {f"turn-{t['n']:04d}.jsonl": data})
262
+ sync() # dirty local edits predate the queued turn: commit them outside it
263
+ t.update(n=t["n"] + 1, start=head(), toffset=boundary); t.pop("stopped", None)
264
+ t.update(soffset=end, seen=1 if bs else seen + len(prompts))
265
+ turns[session_key(sid, harness)] = t
266
+ save_state("turns.json", turns)
267
+ def stop_chunk(sid, payload, harness="claude"):
268
+ # Stop: append this turn's transcript slice to the session ref
269
+ turns = state("turns.json", {}); t = turns.get(session_key(sid, harness))
270
+ tp = payload.get("transcript_path") or (t or {}).get("transcript", "")
271
+ if not (t and tp and os.path.exists(tp) and cfg_track()): return
272
+ f = open(tp, errors="replace"); f.seek(t.get("toffset", 0)); data = f.read()
273
+ if data: store_chunks(sid, {f"turn-{t['n']:04d}.jsonl": data})
274
+ t.update(toffset=os.path.getsize(tp), stopped=True); save_state("turns.json", turns)
275
+ def subagent_chunk(sid, payload):
276
+ tp, sub = payload.get("agent_transcript_path"), payload.get("agent_id")
277
+ if not (tp and sub and os.path.exists(tp) and cfg_track()): return
278
+ store_chunks(sid, {f"subagents/{sub}/transcript.jsonl": open(tp, errors="replace").read()})
279
+ def cmd_hook(a):
280
+ try: payload = json.load(sys.stdin)
281
+ except (ValueError, OSError): payload = {}
282
+ try: payload.get("cwd") and os.chdir(payload["cwd"])
283
+ except OSError: return
284
+ p = quiet("git", "rev-parse", "--show-toplevel")
285
+ if p.returncode or not os.path.isdir(os.path.join(p.stdout.strip(), ".git", "git-sci")):
286
+ return # not a git-sci repo; hooks must never break the harness
287
+ os.chdir(p.stdout.strip())
288
+ lk = lock() # serialize concurrent hooks (parallel sessions/subagents)
289
+ event, sid, harness = (payload.get("hook_event_name", ""),
290
+ payload.get("session_id", "?"), a.harness)
291
+ active = state("active.json", {})
292
+ if event == "UserPromptSubmit" and not state("turns.json", {}).get(
293
+ session_key(sid, harness), {}).get("stopped"): # Stop never fired:
294
+ squash_turn(sid, harness) # squash the turn + store its transcript slice
295
+ stop_chunk(sid, payload, harness)
296
+ elif event == "PreToolUse": prompt_boundary(sid, payload, harness)
297
+ if event in ("SessionStart", "SubagentStart", "UserPromptSubmit", "PreToolUse"):
298
+ sync() # everything dirty before an agent tool runs is <user>/local
299
+ if event == "SessionStart": # stdout lands in the agent's context
300
+ native = "~/.claude/projects" if harness == "claude" else "~/.codex/sessions"
301
+ print(f"git-sci: past sessions for this repo (yours + teammates') are in "
302
+ f".git/git-sci/sessions/, not {native}; {export_sessions()} sessions")
303
+ elif event == "SubagentStart":
304
+ print("git-sci: past sessions for this repo are in .git/git-sci/sessions/; "
305
+ "use `git-sci sessions` to search them")
306
+ elif event == "UserPromptSubmit":
307
+ turns, tp = state("turns.json", {}), payload.get("transcript_path", "")
308
+ key = session_key(sid, harness)
309
+ turns[key] = {"n": turns.get(key, {}).get("n", 0) + 1, "start": head(),
310
+ "transcript": tp,
311
+ "toffset": os.path.getsize(tp) if os.path.exists(tp) else 0}
312
+ save_state("turns.json", turns)
313
+ elif event == "PreToolUse": # claim the file BEFORE the write exists
314
+ tool, inp = payload.get("tool_name", "?"), payload.get("tool_input") or {}
315
+ active[call_key(sid, payload, harness)] = {"files": relative_tool_files(tool, inp)}
316
+ save_state("active.json", active)
317
+ elif event == "PostToolUse":
318
+ finish_call(sid, active, payload, harness)
319
+ elif event == "Stop": # a queued prompt may end in a text-only turn: no PreToolUse
320
+ prompt_boundary(sid, payload, harness)
321
+ squash_turn(sid, harness)
322
+ stop_chunk(sid, payload, harness)
323
+ elif event == "SubagentStop":
324
+ subagent_chunk(sid, payload)
325
+ enc = lambda path: re.sub(r"[/.]", "-", path)
326
+ def project_dirs(): # every ~/.claude project dir that maps to this repo (worktrees too)
327
+ wt = [l.split(" ", 1)[1] for l in git("worktree", "list", "--porcelain", check=False)
328
+ .splitlines() if l.startswith("worktree ")]
329
+ names = {enc(p) for r in [root()] + wt for p in (r, os.path.realpath(r))}
330
+ return [d for d in glob.glob(os.path.join(projects(), "*")) if os.path.basename(d) in names
331
+ or any(os.path.basename(d).startswith(n + "--claude-worktrees-") for n in names)]
332
+ def repo_locations():
333
+ wt = [l.split(" ", 1)[1] for l in git("worktree", "list", "--porcelain", check=False)
334
+ .splitlines() if l.startswith("worktree ")]
335
+ return {os.path.realpath(p) for p in [root()] + wt}
336
+ def codex_meta(path):
337
+ try:
338
+ for line in open(path, errors="replace"):
339
+ try: obj = json.loads(line)
340
+ except ValueError: continue
341
+ if obj.get("type") == "session_meta": return obj.get("payload") or {}
342
+ except OSError: pass
343
+ return {}
344
+ def transcript_sources(harness="claude"):
345
+ if harness == "claude":
346
+ for d in project_dirs():
347
+ for path in glob.glob(os.path.join(d, "**", "*.jsonl"), recursive=True):
348
+ rp = os.path.relpath(path, d).split(os.sep)
349
+ sid, sub = (rp[0], rp[-1][:-6]) if len(rp) > 1 else (rp[0][:-6], None)
350
+ yield path, sid, sub
351
+ return
352
+ roots = repo_locations()
353
+ for path in glob.glob(os.path.join(codex_sessions(), "**", "*.jsonl"), recursive=True):
354
+ meta = codex_meta(path); cwd = os.path.realpath(meta.get("cwd") or "")
355
+ if not cwd or not any(cwd == r or cwd.startswith(r + os.sep) for r in roots): continue
356
+ sid = meta.get("id") or os.path.basename(path)[:-6].rsplit("-", 5)[-1]
357
+ source = str(meta.get("source") or "").lower()
358
+ yield path, sid, sid if "subagent" in source else None
359
+ def scan_claude_transcript(path, sid):
360
+ chunks, cur, calls = [], [], []
361
+ for line in open(path, errors="replace"):
362
+ if cur and "tool_result" not in line \
363
+ and ('"type":"user"' in line or '"type": "user"' in line):
364
+ chunks.append("".join(cur)); cur = []
365
+ cur.append(line)
366
+ if '"tool_use"' not in line or '"tool_result"' in line: continue
367
+ try: obj = json.loads(line)
368
+ except ValueError: continue
369
+ for b in (obj.get("message") or {}).get("content") or []:
370
+ f = (isinstance(b, dict) and b.get("type") == "tool_use"
371
+ and b.get("name") in EDIT_TOOLS and tool_file(b.get("input") or {}))
372
+ if f and obj.get("timestamp"):
373
+ try:
374
+ t = datetime.fromisoformat(obj["timestamp"].replace("Z", "+00:00"))
375
+ calls.append((t.timestamp(), sid, b.get("id"), f, obj.get("cwd") or ""))
376
+ except ValueError: pass
377
+ if cur: chunks.append("".join(cur))
378
+ return chunks, calls
379
+ def scan_codex_transcript(path, sid):
380
+ lines = open(path, errors="replace").readlines()
381
+ parsed = []
382
+ for line in lines:
383
+ try: parsed.append(json.loads(line))
384
+ except ValueError: parsed.append({})
385
+ task_boundaries = any(o.get("type") == "event_msg" and
386
+ (o.get("payload") or {}).get("type") == "task_started" for o in parsed)
387
+ chunks, cur, calls, cwd = [], [], [], ""
388
+ for line, obj in zip(lines, parsed):
389
+ payload = obj.get("payload") or {}
390
+ if obj.get("type") in ("session_meta", "turn_context"):
391
+ cwd = payload.get("cwd") or cwd
392
+ boundary = (obj.get("type") == "event_msg" and payload.get("type") == "task_started")
393
+ if not task_boundaries:
394
+ boundary = ((obj.get("type") == "response_item" and
395
+ payload.get("type") == "message" and payload.get("role") == "user") or
396
+ (obj.get("type") == "event_msg" and payload.get("type") == "user_message"))
397
+ if boundary and cur: chunks.append("".join(cur)); cur = []
398
+ cur.append(line)
399
+ if obj.get("type") != "response_item" or payload.get("type") not in (
400
+ "function_call", "custom_tool_call"): continue
401
+ tool = payload.get("name") or ""
402
+ raw = payload.get("arguments") if payload.get("arguments") is not None \
403
+ else payload.get("input")
404
+ inp = raw
405
+ if isinstance(raw, str):
406
+ try: inp = json.loads(raw)
407
+ except ValueError: inp = {}
408
+ files = tool_files(tool, inp) or patch_files(raw)
409
+ if not (files and obj.get("timestamp")): continue
410
+ try: epoch = datetime.fromisoformat(obj["timestamp"].replace("Z", "+00:00")).timestamp()
411
+ except ValueError: continue
412
+ calls += [(epoch, sid, payload.get("call_id") or payload.get("id"), f, cwd)
413
+ for f in files]
414
+ if cur: chunks.append("".join(cur))
415
+ return chunks, calls
416
+ def scan_transcript(path, sid, harness="claude"):
417
+ return (scan_claude_transcript(path, sid) if harness == "claude" else
418
+ scan_codex_transcript(path, sid))
419
+ def backfill(index): # attribute pre-existing commits: transcript_backfill on a files+time
420
+ # window match, else <author>/local with method import; commits with events are skipped
421
+ have, att, tot, cs, cur = {e.get("commit") for e in events()}, 0, 0, [], None
422
+ # -c: merges list their conflict-resolution files (result differs from every parent)
423
+ for l in git("log", "--name-only", "-c", "--pretty=#%H\t%an\t%at", check=False).splitlines():
424
+ if l.startswith("#"): cur = l[1:].split("\t") + [[]]; cs.append(cur)
425
+ elif l.strip() and cur: cur[3].append(l)
426
+ for H, an, at, files in cs:
427
+ if H in have: continue
428
+ tot += 1
429
+ m = {f: c for f in files for c in # nearest call in the window, across harnesses
430
+ [min((c for c in index.get(f, []) if abs(c[0] - int(at)) < 1800),
431
+ key=lambda c: abs(c[0] - int(at)), default=None)] if c}
432
+ u = an.replace(" ", "-").lower()
433
+ match = min(m.values(), key=lambda c: abs(c[0] - int(at)), default=None)
434
+ actor = agent(match[1], match[3], u) if match else local(u)
435
+ record(mirror=False, type="edit", actor=actor, commit=H, edits=[
436
+ {"file": f, "patch": "", "tool_call_id": m[f][2] if f in m else None,
437
+ "attribution": {"method": "transcript_backfill" if f in m else "import"}}
438
+ for f in files])
439
+ att += bool(m)
440
+ return att, tot
441
+ def do_import(harnesses=("claude",)): # index every harness first: backfill runs once
442
+ t0, seen, index, ns, nsub = time.time(), state("imported.json", {}), {}, 0, 0
443
+ for harness in harnesses:
444
+ for path, sid, sub in transcript_sources(harness):
445
+ st = os.stat(path)
446
+ if seen.get(path) == [st.st_size, int(st.st_mtime)]:
447
+ continue # idempotent: only new/changed transcripts are re-read
448
+ chunks, calls = scan_transcript(path, sid, harness)
449
+ for t, s, tid, f, cw in calls: # strip repo root or the transcript's own cwd,
450
+ pre = next((p + "/" for p in (root(), os.path.realpath(root()), cw) # clones too
451
+ if p and f.startswith(p + "/")), "")
452
+ fr = re.sub(r"^\.claude/worktrees/[^/]+/", "", f[len(pre):].lstrip("/"))
453
+ index.setdefault(fr, []).append((t, s, tid, harness))
454
+ if cfg_track():
455
+ pre = f"subagents/{sub}/" if sub else ""
456
+ store_chunks(sid, {f"{pre}turn-{i:04d}.jsonl": c for i, c in enumerate(chunks, 1)})
457
+ seen[path] = [st.st_size, int(st.st_mtime)]
458
+ ns, nsub = ns + (not sub), nsub + bool(sub)
459
+ save_state("imported.json", seen)
460
+ att, tot = backfill(index); mirror_log()
461
+ return (f"imported {ns} sessions, {nsub} subagents; "
462
+ f"attributed {att}/{tot} commits in {time.time() - t0:.1f}s")
463
+ session_rows = lambda: [(r.split("/", 4)[4], r.split("/")[3], r) # (sid, user, ref)
464
+ for r, _ in refs("refs/git-sci/sessions/")]
465
+ def session_text(ref, sub=None):
466
+ pre = f"subagents/{sub}/" if sub else ""
467
+ names = sorted(n for n in git("ls-tree", "-r", "--name-only", ref).splitlines()
468
+ if n.startswith(pre) and n.count("/") == pre.count("/"))
469
+ return "".join(git("cat-file", "blob", f"{ref}:{n}") for n in names)
470
+ def export_sessions(): # mirror session refs to .git/git-sci/sessions/ in ~/.claude/projects layout
471
+ base, idx = os.path.join(git_sci_dir(), "sessions", enc(root())), []
472
+ os.makedirs(base, exist_ok=True)
473
+ for sid, u, ref in session_rows():
474
+ fp = os.path.join(base, sid + ".jsonl")
475
+ open(fp, "w").write(session_text(ref))
476
+ for s in sorted({n.split("/")[1] for n in git("ls-tree", "-r", "--name-only", ref)
477
+ .splitlines() if n.startswith("subagents/")}):
478
+ os.makedirs(os.path.join(base, sid, "subagents"), exist_ok=True)
479
+ open(os.path.join(base, sid, "subagents", s + ".jsonl"), "w").write(session_text(ref, s))
480
+ idx.append({"id": sid, "user": u, "path": fp, "origin": "local" if u == user() else "team"})
481
+ jwrite(os.path.join(base, "sessions-index.json"), idx)
482
+ return len(idx)
483
+ def cmd_sessions(a):
484
+ if a.id == "export":
485
+ return print(f"git-sci: exported {export_sessions()} sessions to .git/git-sci/sessions/")
486
+ rows = session_rows()
487
+ if a.id:
488
+ for sid, u, ref in rows:
489
+ if sid.startswith(a.id): return print(session_text(ref), end="")
490
+ sys.exit("git-sci: no such session")
491
+ pat = a.grep or (a.file and re.escape(a.file))
492
+ for sid, u, ref in rows:
493
+ if pat and not re.search(pat, session_text(ref)): continue
494
+ print(f"{sid} {u} {len(git('ls-tree', '--name-only', ref).splitlines())} turns")
495
+ def chunk_prompt(text): # first user message of a stored transcript chunk (claude or codex)
496
+ for o in parse_events(text):
497
+ m, p = o.get("message") or {}, o.get("payload") or {}
498
+ if o.get("type") == "user" and isinstance(m, dict):
499
+ c = m.get("content")
500
+ t = c if isinstance(c, str) else "\n".join(
501
+ b.get("text", "") for b in c or []
502
+ if isinstance(b, dict) and b.get("type") == "text")
503
+ if t: return t
504
+ if o.get("type") == "event_msg" and p.get("type") == "user_message":
505
+ return p.get("message")
506
+ if o.get("type") == "response_item" and p.get("type") == "message" \
507
+ and p.get("role") == "user":
508
+ return "".join(b.get("text", "") for b in p.get("content") or []
509
+ if isinstance(b, dict))
510
+ def stdout_file(): # file backing a redirected stdout (`git-sci export > dump.jsonl`)
511
+ try:
512
+ p = (fcntl.fcntl(1, fcntl.F_GETPATH, bytes(1024)).rstrip(b"\0").decode()
513
+ if hasattr(fcntl, "F_GETPATH") else os.readlink("/proc/self/fd/1"))
514
+ return [rel_to_root(p)] if os.path.isfile(p) else []
515
+ except OSError: return []
516
+ def cmd_export(a): # JSONL fine-tuning dump: one record per turn/op, prompt paired with edits
517
+ maybe_sync([rel_to_root(a.output)] if a.output else stdout_file()) # never re-ingest our dump
518
+ rows, evs, merged = session_rows(), events(), {}
519
+ squashed = {i for e in evs for i in e.get("squashes", [])}
520
+ for e in evs: # squashed calls and overlay re-records are subsumed by their turn events
521
+ if e.get("type") not in ("edit", "undo") or e["id"] in squashed or "overlay_of" in e \
522
+ or not e.get("edits"): continue
523
+ key = (e["actor"], e["turn"]) if e.get("turn") else e["id"] # unsquashed turns regroup
524
+ m = merged.setdefault(key, {**e, "tool_calls": e.get("tool_calls", [])[:],
525
+ "edits": e["edits"][:]})
526
+ if m["id"] != e["id"]: m["tool_calls"] += e.get("tool_calls", []); m["edits"] += e["edits"]
527
+ out = open(a.output, "w") if a.output else sys.stdout
528
+ for e in merged.values():
529
+ sid = prompt = None
530
+ m = re.match(r"([^/]+)/agent:[^/]+/(.+)", e["actor"])
531
+ hit = m and next(((s, r) for s, u, r in rows
532
+ if u == m.group(1) and s.startswith(m.group(2))), None)
533
+ if hit:
534
+ sid, n = hit[0], int(e.get("turn", "turn0")[4:])
535
+ prompt = n and chunk_prompt(git("cat-file", "blob",
536
+ f"{hit[1]}:turn-{n:04d}.jsonl", check=False)) or None
537
+ out.write(json.dumps({"timestamp": e["timestamp"], "actor": e["actor"], "session": sid,
538
+ "turn": e.get("turn"), "prompt": prompt,
539
+ "tool_calls": e["tool_calls"],
540
+ "edits": [{"file": d["file"], "patch": d["patch"] or git(
541
+ "show", "--format=", e.get("commit") or "", "--", d["file"],
542
+ check=False)} for d in e["edits"]]}) + "\n")
543
+ if a.output: out.close(); print(f"git-sci: exported to {a.output}")
544
+ def cmd_gc(a):
545
+ old = [r for r, sha in refs("refs/git-sci/sessions/")
546
+ if int(git("show", "-s", "--format=%ct", sha).strip()) < time.time() - a.older_than * 86400]
547
+ for r in old: git("update-ref", "-d", r)
548
+ print(f"git-sci: dropped {len(old)} session refs (git gc will prune the objects)")
549
+ def cfg_track(): # ask once; tty only, so never on hook runs; default yes
550
+ cfg = state("config.json", {})
551
+ if "track_sessions" not in cfg:
552
+ cfg["track_sessions"] = (not sys.stdin.isatty() or
553
+ input("git-sci: track sessions in git? [Y/n] ").strip().lower() != "n")
554
+ save_state("config.json", cfg)
555
+ return cfg["track_sessions"]
556
+ def detect_harnesses(): # every harness with state on this machine (GIT_SCI_* overrides apply)
557
+ hs = [h for h, home, d in (("claude", "~/.claude", projects()),
558
+ ("codex", "~/.codex", codex_sessions()))
559
+ if os.path.isdir(os.path.expanduser(home)) or os.path.isdir(d)]
560
+ return hs or ["claude"]
561
+ def configured_harnesses():
562
+ cfg = state("config.json", {})
563
+ return cfg.get("harnesses") or [cfg.get("harness", "claude")]
564
+ hook_file = lambda h: ".claude/settings.json" if h == "claude" else ".codex/hooks.json"
565
+ hook_cmd = lambda h: "git-sci hook" if h == "claude" else "git-sci hook --harness codex"
566
+ def install_hooks(harness="claude"):
567
+ rel = hook_file(harness)
568
+ path = os.path.join(root(), rel)
569
+ os.makedirs(os.path.dirname(path), exist_ok=True)
570
+ cfg = jread(path, {})
571
+ if not isinstance(cfg.setdefault("hooks", {}), dict):
572
+ sys.exit(f"git-sci: unexpected 'hooks' format in {rel}")
573
+ command = hook_cmd(harness)
574
+ hook_events = HOOK_EVENTS if harness == "claude" else CODEX_HOOK_EVENTS
575
+ for event in hook_events:
576
+ group = cfg["hooks"].setdefault(event, [])
577
+ if not any(h.get("command") == command for g in group for h in g.get("hooks", [])):
578
+ group.append({"hooks": [{"type": "command", "command": command}]})
579
+ jwrite(path, cfg)
580
+ return f"{harness} ({rel})"
581
+ def remove_hooks(harness): # a restrictive re-init un-installs the excluded harness's hook
582
+ path, command = os.path.join(root(), hook_file(harness)), hook_cmd(harness)
583
+ cfg = jread(path, {})
584
+ if not isinstance(cfg.get("hooks"), dict): return
585
+ for event in list(cfg["hooks"]):
586
+ for g in cfg["hooks"][event]:
587
+ g["hooks"] = [h for h in g.get("hooks", []) if h.get("command") != command]
588
+ cfg["hooks"][event] = [g for g in cfg["hooks"][event] if g.get("hooks")]
589
+ if not cfg["hooks"][event]: del cfg["hooks"][event]
590
+ jwrite(path, cfg)
591
+ def install_push(): # travel: fetch refspec + fail-soft pre-push mirror of refs/git-sci/*
592
+ if "origin" in git("remote", check=False).split():
593
+ spec = "+refs/git-sci/*:refs/git-sci/*"
594
+ if spec not in git("config", "--get-all", "remote.origin.fetch", check=False):
595
+ git("config", "--add", "remote.origin.fetch", spec)
596
+ hp = os.path.join(root(), ".git", "hooks", "pre-push")
597
+ if not os.path.exists(hp):
598
+ open(hp, "w").write('#!/bin/sh\n# git-sci: mirror op log + sessions, fail-soft\n'
599
+ 'git push --no-verify "$1" "+refs/git-sci/*:refs/git-sci/*" >/dev/null 2>&1 || true\n')
600
+ os.chmod(hp, 0o755)
601
+ def agent_instructions(harnesses, choice, flags=""): # one prompt covering every harness file
602
+ todo = []
603
+ for h in harnesses:
604
+ name, line = ("CLAUDE.md", CLAUDE_LINE) if h == "claude" else ("AGENTS.md", CODEX_LINE)
605
+ p = os.path.join(root(), name)
606
+ cur = open(p).read() if os.path.exists(p) else ""
607
+ if line not in cur: todo.append((p, cur, line, name))
608
+ if not todo: return
609
+ if choice is None and sys.stdin.isatty():
610
+ choice = input(f"git-sci: add one line to {'/'.join(t[3] for t in todo)} so agents find "
611
+ "team sessions? [Y/n] ").strip().lower() != "n"
612
+ if choice:
613
+ for p, cur, line, _ in todo:
614
+ open(p, "a").write(("\n" if cur and not cur.endswith("\n") else "") + line + "\n")
615
+ else:
616
+ print(f"git-sci: run `git-sci init{flags} --instructions` later to enable team session sharing")
617
+ def cmd_init(a):
618
+ if quiet("git", "rev-parse", "--git-dir").returncode: sh("git", "init", "-q")
619
+ os.makedirs(os.path.join(root(), ".git", "git-sci"), exist_ok=True)
620
+ explicit = [h for h, f in (("claude", a.claude), ("codex", a.codex)) if f]
621
+ harnesses = explicit or detect_harnesses()
622
+ cfg = state("config.json", {}); cfg.pop("harness", None)
623
+ cfg["harnesses"] = harnesses; save_state("config.json", cfg)
624
+ if "origin" in git("remote", check=False).split(): # fresh clone: fetch team refs
625
+ git("fetch", "-q", "origin", "+refs/git-sci/*:refs/git-sci/*", check=False)
626
+ p, sha = os.path.join(git_sci_dir(), "events.jsonl"), git( # rebuild cache from our own ref
627
+ "rev-parse", "-q", "--verify", f"refs/git-sci/log/{user()}", check=False).strip()
628
+ if sha and not os.path.exists(p): open(p, "w").write(git("cat-file", "blob", sha))
629
+ installed = ", ".join(install_hooks(h) for h in harnesses); install_push()
630
+ for h in ("claude", "codex"):
631
+ if explicit and h not in explicit: remove_hooks(h)
632
+ # before the initial-state commit: an instruction line written after it would sit dirty
633
+ # until the first prompt, then be committed as a <user>/local edit of CLAUDE.md/AGENTS.md
634
+ agent_instructions(harnesses, a.instructions, "".join(" --" + h for h in explicit))
635
+ if quiet("git", "rev-parse", "HEAD").returncode or changed_files():
636
+ c = commit_files(["."], "git-sci: initial state", local())
637
+ record(type="init", actor=local(), commit=c, edits=[])
638
+ print(f"git-sci: initialized {root()} (hooks: {installed}); {do_import(harnesses)}")
639
+ cmd_viz(argparse.Namespace(n=0, output=os.path.join(git_sci_dir(), "viz.html")))
640
+ print(f"open file://{git_sci_dir()}/viz.html to see your history")
641
+ def cmd_sync(a):
642
+ ev = sync(); print(f"git-sci: committed local edits as op {ev['id']}" if ev else "git-sci: nothing to sync")
643
+ def cmd_import(a):
644
+ print("git-sci: " + do_import(configured_harnesses()))
645
+ def cmd_update(a): # uv re-resolves the git source to the latest commit
646
+ try: p = quiet("uv", "tool", "upgrade", "git-sci")
647
+ except OSError: p = None
648
+ if not p or p.returncode:
649
+ sys.exit("git-sci: could not update via uv; run "
650
+ "`uv tool install --force git+https://github.com/sundial-org/git-sci`")
651
+ print(f"git-sci: {(p.stderr.strip().splitlines() or ['updated'])[-1]}")
652
+ def cmd_log(a):
653
+ maybe_sync()
654
+ by_commit = {}
655
+ for ev in events(): by_commit.setdefault(ev.get("commit"), []).append(ev)
656
+ for line in git("log", f"-{a.n}", "--pretty=%H\t%h\t%ad\t%s", "--date=short").splitlines():
657
+ H, h, date, subj = line.split("\t", 3)
658
+ tags = "".join(f" [{e['actor']}" + (f" {e['turn']}" if e.get("turn") else "")
659
+ + (f" pr#{e['pr']}" if e.get("pr") else "")
660
+ + (" undo" if e["type"] == "undo" else "") + "]"
661
+ for e in by_commit.get(H, []))
662
+ print(f"{h} {date} {subj}{tags or ' [untracked]'}")
663
+ def cmd_show(a):
664
+ maybe_sync(); H = git("rev-parse", a.commit).strip()
665
+ print(git("show", "--stat", "--pretty=format:commit %h %ad%n%s%n", "--date=short", H))
666
+ evs = [e for e in events() if e.get("commit") == H]
667
+ if not evs: # squash-merge/rebase moved the sha: match events by patch-id instead
668
+ pid = patch_id(H); evs = [e for e in events() if pid and e.get("patch_id") == pid]
669
+ if not evs: print("no git-sci operations recorded for this commit")
670
+ for e in evs: print(json.dumps(e, indent=2))
671
+ def blame_context(chunk, tid): # first user prompt + last agent text before tool call `tid`
672
+ prompt = ctx = tool = None
673
+ for obj in parse_events(chunk):
674
+ p = obj.get("payload") or {}
675
+ m = obj.get("message") or (p if p.get("type") == "message" else {})
676
+ if p.get("type") in ("function_call", "custom_tool_call") and \
677
+ tid in (p.get("call_id"), p.get("id")):
678
+ tool = p.get("name")
679
+ if p.get("type") == "user_message" and not prompt: prompt = p.get("message")
680
+ c, role = m.get("content"), m.get("role")
681
+ for b in [{"type": "text", "text": c}] if isinstance(c, str) else \
682
+ (c if isinstance(c, list) else []):
683
+ if not isinstance(b, dict): continue
684
+ if b.get("type") == "tool_use" and b.get("id") == tid: tool = b.get("name")
685
+ elif b.get("type") in ("text", "input_text") and role == "user" and not prompt:
686
+ prompt = b.get("text")
687
+ elif b.get("type") in ("text", "output_text") and role == "assistant":
688
+ ctx = b.get("text")
689
+ if tool: break
690
+ return prompt, ctx, tool
691
+ def cmd_blame(a):
692
+ maybe_sync()
693
+ d, b = os.path.split(a.file) # blame a symlink itself: resolve only its directory
694
+ f = os.path.relpath(os.path.join(os.path.realpath(d or "."), b), root())
695
+ out = git("-c", "core.quotepath=false", "blame", "--porcelain",
696
+ "-L", f"{a.line},{a.line}", "--", f)
697
+ H, orig = out.split()[:2] # originating commit + the line's number in it
698
+ if H == "0" * 40: sys.exit(f"git-sci: {f}:{a.line} is uncommitted")
699
+ of = next((l[9:] for l in out.splitlines() if l.startswith("filename ")), f) # pre-rename path
700
+ touches = lambda e: any(ed.get("file") == of for ed in e.get("edits") or [])
701
+ all_ev = events()
702
+ evs = [e for e in all_ev if e.get("commit") == H]
703
+ if not evs: # squash-merge/rebase moved the sha: match events by patch-id instead
704
+ pid = patch_id(H); evs = [e for e in all_ev if pid and e.get("patch_id") == pid]
705
+ ev = next((e for e in evs if e.get("overlay_of") and touches(e)), # overlay owns its lines
706
+ next((e for e in evs if touches(e)), evs[0] if evs else None))
707
+ sq = next((e for e in evs if e.get("squashes")), None)
708
+ if sq: # re-blame the preserved per-call commits: exact call + actor by line position
709
+ by_id = {e["id"]: e for e in all_ev}
710
+ tip = next((by_id[i].get("commit") for i in reversed(sq["squashes"]) if i in by_id), None)
711
+ if sq.get("commit") != H: # patch-id match: translate the line into the original squash
712
+ ws = lambda rev: ["".join(l.split()) for l in # patch-id ignores all whitespace
713
+ quiet("git", "show", f"{rev}:{of}").stdout.splitlines()]
714
+ i, sm = int(orig) - 1, difflib.SequenceMatcher(None, ws(H), ws(sq["commit"]))
715
+ orig = next((j1 + i - i1 + 1 for t, i1, i2, j1, j2 in sm.get_opcodes()
716
+ if t == "equal" and i1 <= i < i2), None)
717
+ o = tip and orig and quiet("git", "-c", "core.quotepath=false", "blame", "--porcelain",
718
+ "-L", f"{orig},{orig}", tip, "--", of)
719
+ if o and not o.returncode and o.stdout:
720
+ c = o.stdout.split(None, 1)[0] # renamed within the turn: events use the old path
721
+ of = next((l[9:] for l in o.stdout.splitlines() if l.startswith("filename ")), of)
722
+ ev = next((e for e in all_ev if e.get("commit") == c and touches(e)), ev)
723
+ print(f"{f}:{a.line} {H[:8]} " + (f"{ev['actor']}"
724
+ + (f" {ev['turn']}" if ev.get("turn") else "") + f" op {ev['id']}" if ev else "untracked"))
725
+ print("> " + next((l[1:] for l in out.splitlines() if l.startswith("\t")), ""))
726
+ if not ev: return print("no git-sci operations recorded for this commit")
727
+ if "/agent:" not in ev["actor"]:
728
+ return print(f"human edit ({ev['actor']}): no conversation\n" + json.dumps(ev, indent=2))
729
+ u, sid8, tcs = ev["actor"].split("/", 1)[0], ev["actor"].rsplit("/", 1)[1], ev.get("tool_calls") or []
730
+ mine = [tc for tc in tcs if of in ((tc.get("input") or {}).get("files")
731
+ or [(tc.get("input") or {}).get("file")])]
732
+ tid = next((ed["tool_call_id"] for ed in ev.get("edits") or []
733
+ if ed.get("file") == of and ed.get("tool_call_id")), None) \
734
+ or next((tc["id"] for tc in mine + tcs if tc.get("id")), None)
735
+ hit = next(((sid, ref) for sid, ru, ref in session_rows()
736
+ if ru == u and sid.startswith(sid8)), None)
737
+ if not (tid and hit): return print("no transcript stored for this edit")
738
+ sid, ref = hit
739
+ for n in sorted(git("ls-tree", "-r", "--name-only", ref).splitlines()):
740
+ chunk = git("cat-file", "blob", f"{ref}:{n}")
741
+ if tid in chunk: break
742
+ else: return print(f"tool call {tid} not found in session {sid}")
743
+ prompt, ctx, tool = blame_context(chunk, tid)
744
+ print(f"session {sid} ({n}):")
745
+ if prompt: print(f"user: {prompt.strip()}")
746
+ if ctx: print(f"agent: {ctx.strip()}")
747
+ print(f"[{tool or '?'} {tid}]")
748
+ def cmd_undo(a):
749
+ maybe_sync(); actor = local()
750
+ if a.commit is not None:
751
+ H = git("rev-parse", a.commit).strip()
752
+ merge = ["-m", "1"] if len(git("rev-list", "--parents", "-n1", H).split()) > 2 else []
753
+ r = quiet("git", "revert", "--no-commit", "--no-edit", *merge, H)
754
+ if r.returncode:
755
+ quiet("git", "revert", "--abort"); sys.exit(f"git-sci: cannot revert {H[:8]} cleanly")
756
+ files, pre = changed_files(), head()
757
+ c = commit_files(files or ["."], f"git-sci: undo commit {H[:8]}", actor)
758
+ record(type="undo", actor=actor, commit=c, target_commit=H,
759
+ edits=[{"file": f, "patch": patches(pre, c, files)[f]} for f in files])
760
+ return print(f"git-sci: undid commit {H[:8]} in {c[:8]}")
761
+ target = next((e for e in reversed(events())
762
+ if (e["id"] == a.op or not a.op) and e.get("edits")), None)
763
+ if not target: sys.exit("git-sci: no such operation")
764
+ combined = "".join(ed["patch"] for ed in target["edits"] if ed["patch"].strip())
765
+ if quiet("git", "apply", "-R", input=combined).returncode: # atomic: all or nothing
766
+ sys.exit(f"git-sci: cannot undo op {target['id']}: its patch no longer applies cleanly; "
767
+ "try `git-sci undo --commit <hash>` instead")
768
+ files, pre = [ed["file"] for ed in target["edits"]], head()
769
+ c = commit_files(files, f"git-sci: undo op {target['id']}", actor)
770
+ record(type="undo", actor=actor, commit=c, target_op=target["id"],
771
+ edits=[{"file": f, "patch": patches(pre, c, files)[f]} for f in files])
772
+ print(f"git-sci: undid op {target['id']} in {c[:8]}")
773
+ def cmd_viz(a):
774
+ maybe_sync()
775
+ log = git("log", "--exclude=refs/git-sci/*", "--all", *([f"-{a.n}"] if a.n else []), "--date=iso",
776
+ "--pretty=%H\x1f%h\x1f%P\x1f%D\x1f%ad\x1f%s")
777
+ cols = ["hash", "short", "parents", "refs", "date", "subject"]
778
+ commits = [dict(zip(cols, l.split("\x1f"))) for l in log.splitlines()]
779
+ data = json.dumps({"commits": commits, "events": events()}).replace("</", "<\\/")
780
+ out = a.output or os.path.join(git_sci_dir(), "viz.html")
781
+ open(out, "w").write(VIZ.replace("__DATA__", data))
782
+ print(f"git-sci: wrote {out}")
783
+ VIZ = """<!doctype html><meta charset="utf-8"><title>git-sci</title>
784
+ <style>body{margin:0;font:12px/1.5 ui-monospace,Menlo,monospace;background:#fff;color:#000}
785
+ header{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid #000}#st{margin-left:auto}
786
+ main{display:grid;grid-template-columns:1fr 1fr;height:calc(100vh - 40px)}
787
+ #c{margin:0;padding:6px 0;list-style:none;overflow:auto;border-right:1px solid #000}
788
+ #c li{padding:1px 14px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
789
+ #c li:hover{background:#eee}#c li.sel{background:#000;color:#fff}
790
+ #d{margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap}
791
+ button,input{font:inherit;background:#fff;border:1px solid #000;accent-color:#000}</style>
792
+ <header><b>git-sci</b><button id=play>replay</button><input type=range id=s min=0><span id=pos></span><span id=st></span></header>
793
+ <main><ol id=c></ol><pre id=d>select a commit</pre></main>
794
+ <script>const D=__DATA__,cs=D.commits,byC={};D.events.forEach(e=>(byC[e.commit]=byC[e.commit]||[]).push(e));
795
+ const esc=t=>(t||'').replace(/&/g,'&amp;').replace(/</g,'&lt;');
796
+ const cnt={};D.events.forEach(e=>cnt[e.actor]=(cnt[e.actor]||0)+Math.max((e.edits||[]).length,1));
797
+ const tot=Object.values(cnt).reduce((a,b)=>a+b,0)||1;
798
+ st.innerHTML=Object.entries(cnt).sort((a,b)=>b[1]-a[1]).map(([a,n])=>`<b>${esc(a)}</b> ${Math.round(100*n/tot)}%`).join(' | ');
799
+ c.innerHTML=cs.map(x=>`<li>${x.short} ${x.date.slice(0,10)} ${esc(x.subject)}${(byC[x.hash]||[]).map(e=>` <b>[${esc(e.actor)}${e.turn?' '+e.turn:''}${e.pr?' pr#'+e.pr:''}${e.type=='undo'?' undo':''}]</b>`).join('')}</li>`).join('');
800
+ const rows=[...c.children];rows.forEach((li,i)=>li.onclick=()=>{rows.forEach(r=>r.classList.remove('sel'));li.classList.add('sel');detail(cs[i]);});
801
+ s.max=cs.length;s.value=cs.length;
802
+ function cut(){const n=+s.value;pos.textContent=n+'/'+cs.length;rows.forEach((li,i)=>li.style.display=cs.length-1-i<n?'':'none');}
803
+ function detail(x){let o=`commit ${x.hash}\\n${x.date}${x.refs?' ('+x.refs+')':''}\\n${x.subject}\\n`;
804
+ for(const e of byC[x.hash]||[]){o+=`\\nop ${e.id} | ${e.type} | ${e.actor}${e.turn?' | '+e.turn:''}${e.target_op?' | undoes op '+e.target_op:''}${e.target_commit?' | undoes '+e.target_commit.slice(0,8):''}\\n`;
805
+ for(const ed of e.edits||[])o+=` ${ed.file}${ed.tool_call_id?` (tool ${ed.tool_call_id})`:''}\\n`+(ed.patch||'').split('\\n').map(l=>' '+l).join('\\n')+'\\n';}
806
+ if(!(byC[x.hash]||[]).length)o+='\\n(no git-sci operations recorded for this commit)';d.textContent=o;}
807
+ let t=null;play.onclick=()=>{if(t){clearInterval(t);t=null;return}s.value=0;cut();
808
+ t=setInterval(()=>{s.value=+s.value+Math.max(1,Math.ceil(cs.length/150));cut();if(+s.value>=cs.length){clearInterval(t);t=null}},80)};
809
+ s.oninput=cut;cut();</script>
810
+ """
811
+ def main(argv=None):
812
+ ap = argparse.ArgumentParser(prog="git-sci", description=__doc__)
813
+ sub = ap.add_subparsers(dest="cmd", required=True)
814
+ ps = {c: sub.add_parser(c, help=h) for c, h in [
815
+ ("init", "git repo + .git/git-sci + hooks + session import"), ("sync", "commit pending local edits"),
816
+ ("hook", "agent-harness hook entrypoint (JSON on stdin)"), ("show", "show operations for a commit"),
817
+ ("import", "re-import sessions + backfill attribution"), ("gc", "drop session refs older than N days"),
818
+ ("sessions", "list/read/export sessions in refs/git-sci/sessions"), ("log", "git log with actors + turns"),
819
+ ("undo", "undo one operation (default: latest) or a commit"), ("viz", "HTML visualization of the op log"),
820
+ ("update", "update git-sci to the latest version"),
821
+ ("export", "dump history as fine-tuning JSONL (prompt + tool_calls + edits per turn)"),
822
+ ("blame", "the conversation behind a file:line")]}
823
+ ps["init"].add_argument("--claude", action="store_true",
824
+ help="set up Claude Code only (default: every detected harness)")
825
+ ps["init"].add_argument("--codex", action="store_true",
826
+ help="set up Codex only (default: every detected harness)")
827
+ ps["init"].add_argument("--instructions", "--claude-md", dest="instructions",
828
+ action="store_true", default=None)
829
+ ps["init"].add_argument("--no-instructions", "--no-claude-md", dest="instructions",
830
+ action="store_false")
831
+ ps["hook"].add_argument("--harness", choices=("claude", "codex"), default="claude")
832
+ ps["sessions"].add_argument("id", nargs="?"); ps["sessions"].add_argument("--grep")
833
+ ps["sessions"].add_argument("--file"); ps["gc"].add_argument("--older-than", type=int, required=True)
834
+ ps["log"].add_argument("-n", type=int, default=30); ps["show"].add_argument("commit")
835
+ ps["undo"].add_argument("--op", nargs="?", const="", default=None, metavar="ID")
836
+ ps["undo"].add_argument("--commit", nargs="?", const="HEAD", default=None, metavar="HASH")
837
+ ps["viz"].add_argument("-o", "--output", default=None); ps["viz"].add_argument("-n", type=int, default=0)
838
+ ps["export"].add_argument("-o", "--output", default=None)
839
+ ps["blame"].add_argument("file"); ps["blame"].add_argument("line", type=int)
840
+ a = ap.parse_args(argv)
841
+ if a.cmd not in ("init", "hook", "update"): # hook locates its repo from the payload
842
+ if a.cmd == "blame": a.file = os.path.abspath(a.file) # resolve before the chdir
843
+ os.chdir(root()); git_sci_dir(); a.lock = lock()
844
+ {"init": cmd_init, "hook": cmd_hook, "sync": cmd_sync, "log": cmd_log, "show": cmd_show,
845
+ "undo": cmd_undo, "viz": cmd_viz, "import": cmd_import, "sessions": cmd_sessions,
846
+ "gc": cmd_gc, "update": cmd_update, "export": cmd_export, "blame": cmd_blame}[a.cmd](a)
847
+ if __name__ == "__main__":
848
+ main()
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "git-sci"
3
+ version = "0.1.1"
4
+ description = "Share your team's memory - you, your agents, and your teammate's agents."
5
+ requires-python = ">=3.9"
6
+
7
+ [project.scripts]
8
+ git-sci = "git_sci:main"
9
+
10
+ [tool.flit.module]
11
+ name = "git_sci"
12
+
13
+ [build-system]
14
+ requires = ["flit_core>=3.2,<4"]
15
+ build-backend = "flit_core.buildapi"
git_sci-0.0.1/LICENSE DELETED
@@ -1,19 +0,0 @@
1
- Copyright (c) 2018 The Python Packaging Authority
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining a copy
4
- of this software and associated documentation files (the "Software"), to deal
5
- in the Software without restriction, including without limitation the rights
6
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- copies of the Software, and to permit persons to whom the Software is
8
- furnished to do so, subject to the following conditions:
9
-
10
- The above copyright notice and this permission notice shall be included in all
11
- copies or substantial portions of the Software.
12
-
13
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
- SOFTWARE.
git_sci-0.0.1/PKG-INFO DELETED
@@ -1,20 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: git-sci
3
- Version: 0.0.1
4
- Summary: A package for science
5
- Author-email: Belinda Mo <justanexperimentpark@gmail.com>
6
- License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/pypa/sampleproject
8
- Project-URL: Issues, https://github.com/pypa/sampleproject/issues
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.11
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Dynamic: license-file
15
-
16
- # Science
17
-
18
- This is a simple example package. You can use
19
- [GitHub-flavored Markdown](https://guides.github.com/features/mastering-markdown/)
20
- to write your content.
git_sci-0.0.1/README.md DELETED
@@ -1,5 +0,0 @@
1
- # Science
2
-
3
- This is a simple example package. You can use
4
- [GitHub-flavored Markdown](https://guides.github.com/features/mastering-markdown/)
5
- to write your content.
@@ -1,23 +0,0 @@
1
- [project]
2
- name = "git-sci"
3
- version = "0.0.1"
4
- authors = [
5
- { name="Belinda Mo", email="justanexperimentpark@gmail.com" },
6
- ]
7
- description = "A package for science"
8
- readme = "README.md"
9
- requires-python = ">=3.11"
10
- classifiers = [
11
- "Programming Language :: Python :: 3",
12
- "Operating System :: OS Independent",
13
- ]
14
- license = "MIT"
15
- license-files = ["LICEN[CS]E*"]
16
-
17
- [project.urls]
18
- Homepage = "https://github.com/pypa/sampleproject"
19
- Issues = "https://github.com/pypa/sampleproject/issues"
20
-
21
- [build-system]
22
- requires = ["setuptools >= 77.0.3"]
23
- build-backend = "setuptools.build_meta"
git_sci-0.0.1/setup.cfg DELETED
@@ -1,4 +0,0 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
@@ -1,20 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: git-sci
3
- Version: 0.0.1
4
- Summary: A package for science
5
- Author-email: Belinda Mo <justanexperimentpark@gmail.com>
6
- License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/pypa/sampleproject
8
- Project-URL: Issues, https://github.com/pypa/sampleproject/issues
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.11
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Dynamic: license-file
15
-
16
- # Science
17
-
18
- This is a simple example package. You can use
19
- [GitHub-flavored Markdown](https://guides.github.com/features/mastering-markdown/)
20
- to write your content.
@@ -1,9 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- src/git_sci.egg-info/PKG-INFO
5
- src/git_sci.egg-info/SOURCES.txt
6
- src/git_sci.egg-info/dependency_links.txt
7
- src/git_sci.egg-info/top_level.txt
8
- src/science/__init__.py
9
- src/science/example.py
@@ -1 +0,0 @@
1
- science
File without changes
@@ -1,2 +0,0 @@
1
- def add_one(number):
2
- return number + 1