git-sci 0.1.0__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.
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-sci
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Share your team's memory - you, your agents, and your teammate's agents.
5
5
  Requires-Python: >=3.9
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env python3
2
- """git-mem: an operation log for humans + agents on top of git.
2
+ """git-sci: an operation log for humans + agents on top of git.
3
3
 
4
4
  Commits are attributed as <user>/<source>: <user>/local, or <user>/agent:<harness>/<session>.
5
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-mem/ops) and appends the turn's
7
- transcript chunk to refs/git-mem/sessions/<user>/<id>; init imports existing sessions + backfills.
8
- Events: .git/git-mem/events.jsonl (cache) mirrored to refs/git-mem/log/<user>, one writer each, merged
9
- by timestamp at read; refs/git-mem/* are the source of truth and travel via the refspec + pre-push
10
- hook init installs. .git/git-mem/lock (flock) serializes everything, like git's index.lock."""
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
11
  import argparse, difflib, fcntl, glob, hashlib, json, os, re, subprocess, sys
12
12
  import time, uuid, zlib
13
13
  from datetime import datetime
@@ -15,17 +15,17 @@ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"}
15
15
  HOOK_EVENTS = ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop")
16
16
  CODEX_HOOK_EVENTS = HOOK_EVENTS + ("SubagentStart", "SubagentStop")
17
17
  CLAUDE_LINE = ("Past sessions for this repo, yours and other team members', are in "
18
- ".git/git-mem/sessions/ (same layout as ~/.claude/projects/, with a "
18
+ ".git/git-sci/sessions/ (same layout as ~/.claude/projects/, with a "
19
19
  "sessions-index.json). Look there instead of ~/.claude/projects/.")
20
20
  CODEX_LINE = ("Past sessions for this repo, yours and other team members', are in "
21
- ".git/git-mem/sessions/ (with a sessions-index.json). Look there instead of "
22
- "~/.codex/sessions/, or use `git-mem sessions` to search them.")
23
- projects = lambda: os.environ.get("GIT_MEM_PROJECTS", os.path.expanduser("~/.claude/projects"))
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
24
  codex_sessions = lambda: os.environ.get(
25
- "GIT_MEM_CODEX_SESSIONS", os.path.expanduser("~/.codex/sessions"))
25
+ "GIT_SCI_CODEX_SESSIONS", os.path.expanduser("~/.codex/sessions"))
26
26
  def sh(*a, input=None, check=True, env=None):
27
27
  p = subprocess.run(a, capture_output=True, text=True, input=input, env=env)
28
- if check and p.returncode: sys.exit(f"git-mem: `{' '.join(a)}` failed: {p.stderr.strip()}")
28
+ if check and p.returncode: sys.exit(f"git-sci: `{' '.join(a)}` failed: {p.stderr.strip()}")
29
29
  return p.stdout
30
30
  git = lambda *a, **k: sh("git", *a, **k)
31
31
  _r = {}
@@ -42,7 +42,7 @@ def changed_files():
42
42
  def commit_files(files, message, author):
43
43
  git("add", "-A", "--", *files, check=False) # pathspec may legitimately match nothing
44
44
  c = ("-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "--allow-empty",
45
- "--author", f"{author} <git-mem@git-mem>", "-m", message)
45
+ "--author", f"{author} <git-sci@git-sci>", "-m", message)
46
46
  # --only keeps unrelated pre-staged paths out; empty repo matches no pathspec -> plain commit
47
47
  quiet("git", *c, "--only", "--", *files).returncode and git(*c)
48
48
  return head()
@@ -58,16 +58,16 @@ def blob(data): # loose object via hashlib+zlib: no subprocess per chunk (sha1
58
58
  return sha
59
59
  refs = lambda prefix: [l.split("\t") for l in git( # [(refname, sha)]
60
60
  "for-each-ref", "--format=%(refname)\t%(objectname)", prefix, check=False).splitlines()]
61
- def git_mem_dir(must=True): # .git/git-mem is a per-machine cache; refs/git-mem/* are the source of truth
62
- d = os.path.join(root(), ".git", "git-mem")
63
- if must and not os.path.isdir(d): sys.exit("git-mem: not initialized here (run `git-mem init`)")
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
64
  return d
65
65
  def jread(path, default):
66
66
  try: return json.load(open(path))
67
67
  except (OSError, ValueError): return default
68
68
  jwrite = lambda path, obj: json.dump(obj, open(path, "w"), indent=1)
69
- state = lambda name, default: jread(os.path.join(git_mem_dir(), name), default)
70
- save_state = lambda name, obj: jwrite(os.path.join(git_mem_dir(), name), obj)
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
71
  def parse_events(text): # skips torn lines rather than bricking every command
72
72
  out = []
73
73
  for l in text.splitlines():
@@ -75,27 +75,27 @@ def parse_events(text): # skips torn lines rather than bricking every command
75
75
  except ValueError: pass
76
76
  return out
77
77
  def events(): # own cache first (file order), then teammates' logs; stable-sorted
78
- try: evs = parse_events(open(os.path.join(git_mem_dir(), "events.jsonl")).read())
78
+ try: evs = parse_events(open(os.path.join(git_sci_dir(), "events.jsonl")).read())
79
79
  except OSError: evs = []
80
- for ref, sha in refs("refs/git-mem/log/"):
80
+ for ref, sha in refs("refs/git-sci/log/"):
81
81
  if ref.split("/")[-1] != user():
82
82
  evs += parse_events(git("cat-file", "blob", sha, check=False))
83
83
  return sorted(evs, key=lambda e: e.get("timestamp", ""))
84
84
  def lock():
85
- lk = open(os.path.join(git_mem_dir(False), "lock"), "a")
85
+ lk = open(os.path.join(git_sci_dir(False), "lock"), "a")
86
86
  fcntl.flock(lk, fcntl.LOCK_EX)
87
87
  return lk # held until process exit
88
- def mirror_log(): # one writer per user: refs/git-mem/log/<user> mirrors the local cache
89
- p = os.path.join(git_mem_dir(), "events.jsonl")
90
- if os.path.exists(p): git("update-ref", f"refs/git-mem/log/{user()}", blob(open(p).read()))
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
91
  def record(mirror=True, **ev):
92
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_mem_dir(), "events.jsonl"), "a") as f:
93
+ with open(os.path.join(git_sci_dir(), "events.jsonl"), "a") as f:
94
94
  f.write(json.dumps(ev) + "\n")
95
95
  return mirror and mirror_log() or ev
96
- def store_chunks(sid, files, u=None): # {path: text} -> refs/git-mem/sessions/<u>/<sid> tree
96
+ def store_chunks(sid, files, u=None): # {path: text} -> refs/git-sci/sessions/<u>/<sid> tree
97
97
  if not files: return
98
- ref, ix = f"refs/git-mem/sessions/{u or user()}/{sid}", os.path.join(git_mem_dir(), "tmpindex")
98
+ ref, ix = f"refs/git-sci/sessions/{u or user()}/{sid}", os.path.join(git_sci_dir(), "tmpindex")
99
99
  env = {**os.environ, "GIT_INDEX_FILE": ix}
100
100
  if os.path.exists(ix): os.remove(ix)
101
101
  if not quiet("git", "rev-parse", "-q", "--verify", ref).returncode:
@@ -153,7 +153,7 @@ def char_edits(patch): # character-level ranges for paired -/+ lines of a unifi
153
153
  elif not l.startswith("\\") and not l.startswith("diff "):
154
154
  o, n, minus = o + 1, n + 1, []
155
155
  return out
156
- edits_msg = lambda actor, files: (f"git-mem: {actor} edits " + ", ".join(files[:3])
156
+ edits_msg = lambda actor, files: (f"git-sci: {actor} edits " + ", ".join(files[:3])
157
157
  + (f" +{len(files) - 3} more" if len(files) > 3 else ""))
158
158
  def sync(exclude=()): # commit unclaimed changes as <user>/local
159
159
  exclude = set(exclude) | {f for s in state("active.json", {}).values() for f in s["files"]}
@@ -187,11 +187,11 @@ def finish_call(sid, active, payload, harness="claude"):
187
187
  h_files = [f for f in files if f not in a_files]
188
188
  actor, pre = agent(sid, harness), head()
189
189
  turn = state("turns.json", {}).get(session_key(sid, harness), {}).get("n", 0)
190
- c = commit_files(files, f"git-mem: {actor} {tool}" if a_files else edits_msg(local(), files),
190
+ c = commit_files(files, f"git-sci: {actor} {tool}" if a_files else edits_msg(local(), files),
191
191
  actor if a_files else local())
192
192
  p = patches(pre, c, files)
193
193
  if a_files:
194
- git("update-ref", f"refs/git-mem/ops/{c}", c) # survives the turn squash + git gc
194
+ git("update-ref", f"refs/git-sci/ops/{c}", c) # survives the turn squash + git gc
195
195
  record(type="edit", actor=actor, turn=f"turn{turn}", commit=c, patch_id=patch_id(c),
196
196
  tool_calls=[call], edits=[{"file": f, "patch": p[f], "tool_call_id": call["id"],
197
197
  "attribution": {"method": "hook"}} for f in a_files])
@@ -210,9 +210,9 @@ def squash_turn(sid, harness="claude"):
210
210
  mine = [e for e in ops if e["actor"] == actor and "squashes" not in e]
211
211
  if not rng or set(rng) - {e["commit"] for e in mine}: return
212
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-mem@git-mem", "commit-tree",
213
+ c = git("-c", f"user.name={actor}", "-c", "user.email=git-sci@git-sci", "commit-tree",
214
214
  git("rev-parse", "HEAD^{tree}").strip(), "-p", start,
215
- "-m", f"git-mem: {actor} turn {t['n']} ({len(rng)} tool calls)").strip()
215
+ "-m", f"git-sci: {actor} turn {t['n']} ({len(rng)} tool calls)").strip()
216
216
  git("update-ref", "HEAD", c, head())
217
217
  files = git("diff", "--name-only", start, c).splitlines()
218
218
  p = patches(start, c, files)
@@ -282,8 +282,8 @@ def cmd_hook(a):
282
282
  try: payload.get("cwd") and os.chdir(payload["cwd"])
283
283
  except OSError: return
284
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-mem")):
286
- return # not a git-mem repo; hooks must never break the harness
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
287
  os.chdir(p.stdout.strip())
288
288
  lk = lock() # serialize concurrent hooks (parallel sessions/subagents)
289
289
  event, sid, harness = (payload.get("hook_event_name", ""),
@@ -298,11 +298,11 @@ def cmd_hook(a):
298
298
  sync() # everything dirty before an agent tool runs is <user>/local
299
299
  if event == "SessionStart": # stdout lands in the agent's context
300
300
  native = "~/.claude/projects" if harness == "claude" else "~/.codex/sessions"
301
- print(f"git-mem: past sessions for this repo (yours + teammates') are in "
302
- f".git/git-mem/sessions/, not {native}; {export_sessions()} 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
303
  elif event == "SubagentStart":
304
- print("git-mem: past sessions for this repo are in .git/git-mem/sessions/; "
305
- "use `git-mem sessions` to search them")
304
+ print("git-sci: past sessions for this repo are in .git/git-sci/sessions/; "
305
+ "use `git-sci sessions` to search them")
306
306
  elif event == "UserPromptSubmit":
307
307
  turns, tp = state("turns.json", {}), payload.get("transcript_path", "")
308
308
  key = session_key(sid, harness)
@@ -461,14 +461,14 @@ def do_import(harnesses=("claude",)): # index every harness first: backfill run
461
461
  return (f"imported {ns} sessions, {nsub} subagents; "
462
462
  f"attributed {att}/{tot} commits in {time.time() - t0:.1f}s")
463
463
  session_rows = lambda: [(r.split("/", 4)[4], r.split("/")[3], r) # (sid, user, ref)
464
- for r, _ in refs("refs/git-mem/sessions/")]
464
+ for r, _ in refs("refs/git-sci/sessions/")]
465
465
  def session_text(ref, sub=None):
466
466
  pre = f"subagents/{sub}/" if sub else ""
467
467
  names = sorted(n for n in git("ls-tree", "-r", "--name-only", ref).splitlines()
468
468
  if n.startswith(pre) and n.count("/") == pre.count("/"))
469
469
  return "".join(git("cat-file", "blob", f"{ref}:{n}") for n in names)
470
- def export_sessions(): # mirror session refs to .git/git-mem/sessions/ in ~/.claude/projects layout
471
- base, idx = os.path.join(git_mem_dir(), "sessions", enc(root())), []
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
472
  os.makedirs(base, exist_ok=True)
473
473
  for sid, u, ref in session_rows():
474
474
  fp = os.path.join(base, sid + ".jsonl")
@@ -482,12 +482,12 @@ def export_sessions(): # mirror session refs to .git/git-mem/sessions/ in ~/.cl
482
482
  return len(idx)
483
483
  def cmd_sessions(a):
484
484
  if a.id == "export":
485
- return print(f"git-mem: exported {export_sessions()} sessions to .git/git-mem/sessions/")
485
+ return print(f"git-sci: exported {export_sessions()} sessions to .git/git-sci/sessions/")
486
486
  rows = session_rows()
487
487
  if a.id:
488
488
  for sid, u, ref in rows:
489
489
  if sid.startswith(a.id): return print(session_text(ref), end="")
490
- sys.exit("git-mem: no such session")
490
+ sys.exit("git-sci: no such session")
491
491
  pat = a.grep or (a.file and re.escape(a.file))
492
492
  for sid, u, ref in rows:
493
493
  if pat and not re.search(pat, session_text(ref)): continue
@@ -507,7 +507,7 @@ def chunk_prompt(text): # first user message of a stored transcript chunk (clau
507
507
  and p.get("role") == "user":
508
508
  return "".join(b.get("text", "") for b in p.get("content") or []
509
509
  if isinstance(b, dict))
510
- def stdout_file(): # file backing a redirected stdout (`git-mem export > dump.jsonl`)
510
+ def stdout_file(): # file backing a redirected stdout (`git-sci export > dump.jsonl`)
511
511
  try:
512
512
  p = (fcntl.fcntl(1, fcntl.F_GETPATH, bytes(1024)).rstrip(b"\0").decode()
513
513
  if hasattr(fcntl, "F_GETPATH") else os.readlink("/proc/self/fd/1"))
@@ -540,20 +540,20 @@ def cmd_export(a): # JSONL fine-tuning dump: one record per turn/op, prompt pai
540
540
  "edits": [{"file": d["file"], "patch": d["patch"] or git(
541
541
  "show", "--format=", e.get("commit") or "", "--", d["file"],
542
542
  check=False)} for d in e["edits"]]}) + "\n")
543
- if a.output: out.close(); print(f"git-mem: exported to {a.output}")
543
+ if a.output: out.close(); print(f"git-sci: exported to {a.output}")
544
544
  def cmd_gc(a):
545
- old = [r for r, sha in refs("refs/git-mem/sessions/")
545
+ old = [r for r, sha in refs("refs/git-sci/sessions/")
546
546
  if int(git("show", "-s", "--format=%ct", sha).strip()) < time.time() - a.older_than * 86400]
547
547
  for r in old: git("update-ref", "-d", r)
548
- print(f"git-mem: dropped {len(old)} session refs (git gc will prune the objects)")
548
+ print(f"git-sci: dropped {len(old)} session refs (git gc will prune the objects)")
549
549
  def cfg_track(): # ask once; tty only, so never on hook runs; default yes
550
550
  cfg = state("config.json", {})
551
551
  if "track_sessions" not in cfg:
552
552
  cfg["track_sessions"] = (not sys.stdin.isatty() or
553
- input("git-mem: track sessions in git? [Y/n] ").strip().lower() != "n")
553
+ input("git-sci: track sessions in git? [Y/n] ").strip().lower() != "n")
554
554
  save_state("config.json", cfg)
555
555
  return cfg["track_sessions"]
556
- def detect_harnesses(): # every harness with state on this machine (GIT_MEM_* overrides apply)
556
+ def detect_harnesses(): # every harness with state on this machine (GIT_SCI_* overrides apply)
557
557
  hs = [h for h, home, d in (("claude", "~/.claude", projects()),
558
558
  ("codex", "~/.codex", codex_sessions()))
559
559
  if os.path.isdir(os.path.expanduser(home)) or os.path.isdir(d)]
@@ -562,14 +562,14 @@ def configured_harnesses():
562
562
  cfg = state("config.json", {})
563
563
  return cfg.get("harnesses") or [cfg.get("harness", "claude")]
564
564
  hook_file = lambda h: ".claude/settings.json" if h == "claude" else ".codex/hooks.json"
565
- hook_cmd = lambda h: "git-mem hook" if h == "claude" else "git-mem hook --harness codex"
565
+ hook_cmd = lambda h: "git-sci hook" if h == "claude" else "git-sci hook --harness codex"
566
566
  def install_hooks(harness="claude"):
567
567
  rel = hook_file(harness)
568
568
  path = os.path.join(root(), rel)
569
569
  os.makedirs(os.path.dirname(path), exist_ok=True)
570
570
  cfg = jread(path, {})
571
571
  if not isinstance(cfg.setdefault("hooks", {}), dict):
572
- sys.exit(f"git-mem: unexpected 'hooks' format in {rel}")
572
+ sys.exit(f"git-sci: unexpected 'hooks' format in {rel}")
573
573
  command = hook_cmd(harness)
574
574
  hook_events = HOOK_EVENTS if harness == "claude" else CODEX_HOOK_EVENTS
575
575
  for event in hook_events:
@@ -588,15 +588,15 @@ def remove_hooks(harness): # a restrictive re-init un-installs the excluded har
588
588
  cfg["hooks"][event] = [g for g in cfg["hooks"][event] if g.get("hooks")]
589
589
  if not cfg["hooks"][event]: del cfg["hooks"][event]
590
590
  jwrite(path, cfg)
591
- def install_push(): # travel: fetch refspec + fail-soft pre-push mirror of refs/git-mem/*
591
+ def install_push(): # travel: fetch refspec + fail-soft pre-push mirror of refs/git-sci/*
592
592
  if "origin" in git("remote", check=False).split():
593
- spec = "+refs/git-mem/*:refs/git-mem/*"
593
+ spec = "+refs/git-sci/*:refs/git-sci/*"
594
594
  if spec not in git("config", "--get-all", "remote.origin.fetch", check=False):
595
595
  git("config", "--add", "remote.origin.fetch", spec)
596
596
  hp = os.path.join(root(), ".git", "hooks", "pre-push")
597
597
  if not os.path.exists(hp):
598
- open(hp, "w").write('#!/bin/sh\n# git-mem: mirror op log + sessions, fail-soft\n'
599
- 'git push --no-verify "$1" "+refs/git-mem/*:refs/git-mem/*" >/dev/null 2>&1 || true\n')
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
600
  os.chmod(hp, 0o755)
601
601
  def agent_instructions(harnesses, choice, flags=""): # one prompt covering every harness file
602
602
  todo = []
@@ -607,24 +607,24 @@ def agent_instructions(harnesses, choice, flags=""): # one prompt covering ever
607
607
  if line not in cur: todo.append((p, cur, line, name))
608
608
  if not todo: return
609
609
  if choice is None and sys.stdin.isatty():
610
- choice = input(f"git-mem: add one line to {'/'.join(t[3] for t in todo)} so agents find "
610
+ choice = input(f"git-sci: add one line to {'/'.join(t[3] for t in todo)} so agents find "
611
611
  "team sessions? [Y/n] ").strip().lower() != "n"
612
612
  if choice:
613
613
  for p, cur, line, _ in todo:
614
614
  open(p, "a").write(("\n" if cur and not cur.endswith("\n") else "") + line + "\n")
615
615
  else:
616
- print(f"git-mem: run `git-mem init{flags} --instructions` later to enable team session sharing")
616
+ print(f"git-sci: run `git-sci init{flags} --instructions` later to enable team session sharing")
617
617
  def cmd_init(a):
618
618
  if quiet("git", "rev-parse", "--git-dir").returncode: sh("git", "init", "-q")
619
- os.makedirs(os.path.join(root(), ".git", "git-mem"), exist_ok=True)
619
+ os.makedirs(os.path.join(root(), ".git", "git-sci"), exist_ok=True)
620
620
  explicit = [h for h, f in (("claude", a.claude), ("codex", a.codex)) if f]
621
621
  harnesses = explicit or detect_harnesses()
622
622
  cfg = state("config.json", {}); cfg.pop("harness", None)
623
623
  cfg["harnesses"] = harnesses; save_state("config.json", cfg)
624
624
  if "origin" in git("remote", check=False).split(): # fresh clone: fetch team refs
625
- git("fetch", "-q", "origin", "+refs/git-mem/*:refs/git-mem/*", check=False)
626
- p, sha = os.path.join(git_mem_dir(), "events.jsonl"), git( # rebuild cache from our own ref
627
- "rev-parse", "-q", "--verify", f"refs/git-mem/log/{user()}", check=False).strip()
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
628
  if sha and not os.path.exists(p): open(p, "w").write(git("cat-file", "blob", sha))
629
629
  installed = ", ".join(install_hooks(h) for h in harnesses); install_push()
630
630
  for h in ("claude", "codex"):
@@ -633,22 +633,22 @@ def cmd_init(a):
633
633
  # until the first prompt, then be committed as a <user>/local edit of CLAUDE.md/AGENTS.md
634
634
  agent_instructions(harnesses, a.instructions, "".join(" --" + h for h in explicit))
635
635
  if quiet("git", "rev-parse", "HEAD").returncode or changed_files():
636
- c = commit_files(["."], "git-mem: initial state", local())
636
+ c = commit_files(["."], "git-sci: initial state", local())
637
637
  record(type="init", actor=local(), commit=c, edits=[])
638
- print(f"git-mem: initialized {root()} (hooks: {installed}); {do_import(harnesses)}")
639
- cmd_viz(argparse.Namespace(n=0, output=os.path.join(git_mem_dir(), "viz.html")))
640
- print(f"open file://{git_mem_dir()}/viz.html to see your history")
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
641
  def cmd_sync(a):
642
- ev = sync(); print(f"git-mem: committed local edits as op {ev['id']}" if ev else "git-mem: nothing to sync")
642
+ ev = sync(); print(f"git-sci: committed local edits as op {ev['id']}" if ev else "git-sci: nothing to sync")
643
643
  def cmd_import(a):
644
- print("git-mem: " + do_import(configured_harnesses()))
644
+ print("git-sci: " + do_import(configured_harnesses()))
645
645
  def cmd_update(a): # uv re-resolves the git source to the latest commit
646
- try: p = quiet("uv", "tool", "upgrade", "git-mem")
646
+ try: p = quiet("uv", "tool", "upgrade", "git-sci")
647
647
  except OSError: p = None
648
648
  if not p or p.returncode:
649
- sys.exit("git-mem: could not update via uv; run "
650
- "`uv tool install --force git+https://github.com/sundial-org/git-mem`")
651
- print(f"git-mem: {(p.stderr.strip().splitlines() or ['updated'])[-1]}")
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
652
  def cmd_log(a):
653
653
  maybe_sync()
654
654
  by_commit = {}
@@ -666,7 +666,7 @@ def cmd_show(a):
666
666
  evs = [e for e in events() if e.get("commit") == H]
667
667
  if not evs: # squash-merge/rebase moved the sha: match events by patch-id instead
668
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-mem operations recorded for this commit")
669
+ if not evs: print("no git-sci operations recorded for this commit")
670
670
  for e in evs: print(json.dumps(e, indent=2))
671
671
  def blame_context(chunk, tid): # first user prompt + last agent text before tool call `tid`
672
672
  prompt = ctx = tool = None
@@ -695,7 +695,7 @@ def cmd_blame(a):
695
695
  out = git("-c", "core.quotepath=false", "blame", "--porcelain",
696
696
  "-L", f"{a.line},{a.line}", "--", f)
697
697
  H, orig = out.split()[:2] # originating commit + the line's number in it
698
- if H == "0" * 40: sys.exit(f"git-mem: {f}:{a.line} is uncommitted")
698
+ if H == "0" * 40: sys.exit(f"git-sci: {f}:{a.line} is uncommitted")
699
699
  of = next((l[9:] for l in out.splitlines() if l.startswith("filename ")), f) # pre-rename path
700
700
  touches = lambda e: any(ed.get("file") == of for ed in e.get("edits") or [])
701
701
  all_ev = events()
@@ -723,7 +723,7 @@ def cmd_blame(a):
723
723
  print(f"{f}:{a.line} {H[:8]} " + (f"{ev['actor']}"
724
724
  + (f" {ev['turn']}" if ev.get("turn") else "") + f" op {ev['id']}" if ev else "untracked"))
725
725
  print("> " + next((l[1:] for l in out.splitlines() if l.startswith("\t")), ""))
726
- if not ev: return print("no git-mem operations recorded for this commit")
726
+ if not ev: return print("no git-sci operations recorded for this commit")
727
727
  if "/agent:" not in ev["actor"]:
728
728
  return print(f"human edit ({ev['actor']}): no conversation\n" + json.dumps(ev, indent=2))
729
729
  u, sid8, tcs = ev["actor"].split("/", 1)[0], ev["actor"].rsplit("/", 1)[1], ev.get("tool_calls") or []
@@ -752,35 +752,35 @@ def cmd_undo(a):
752
752
  merge = ["-m", "1"] if len(git("rev-list", "--parents", "-n1", H).split()) > 2 else []
753
753
  r = quiet("git", "revert", "--no-commit", "--no-edit", *merge, H)
754
754
  if r.returncode:
755
- quiet("git", "revert", "--abort"); sys.exit(f"git-mem: cannot revert {H[:8]} cleanly")
755
+ quiet("git", "revert", "--abort"); sys.exit(f"git-sci: cannot revert {H[:8]} cleanly")
756
756
  files, pre = changed_files(), head()
757
- c = commit_files(files or ["."], f"git-mem: undo commit {H[:8]}", actor)
757
+ c = commit_files(files or ["."], f"git-sci: undo commit {H[:8]}", actor)
758
758
  record(type="undo", actor=actor, commit=c, target_commit=H,
759
759
  edits=[{"file": f, "patch": patches(pre, c, files)[f]} for f in files])
760
- return print(f"git-mem: undid commit {H[:8]} in {c[:8]}")
760
+ return print(f"git-sci: undid commit {H[:8]} in {c[:8]}")
761
761
  target = next((e for e in reversed(events())
762
762
  if (e["id"] == a.op or not a.op) and e.get("edits")), None)
763
- if not target: sys.exit("git-mem: no such operation")
763
+ if not target: sys.exit("git-sci: no such operation")
764
764
  combined = "".join(ed["patch"] for ed in target["edits"] if ed["patch"].strip())
765
765
  if quiet("git", "apply", "-R", input=combined).returncode: # atomic: all or nothing
766
- sys.exit(f"git-mem: cannot undo op {target['id']}: its patch no longer applies cleanly; "
767
- "try `git-mem undo --commit <hash>` instead")
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
768
  files, pre = [ed["file"] for ed in target["edits"]], head()
769
- c = commit_files(files, f"git-mem: undo op {target['id']}", actor)
769
+ c = commit_files(files, f"git-sci: undo op {target['id']}", actor)
770
770
  record(type="undo", actor=actor, commit=c, target_op=target["id"],
771
771
  edits=[{"file": f, "patch": patches(pre, c, files)[f]} for f in files])
772
- print(f"git-mem: undid op {target['id']} in {c[:8]}")
772
+ print(f"git-sci: undid op {target['id']} in {c[:8]}")
773
773
  def cmd_viz(a):
774
774
  maybe_sync()
775
- log = git("log", "--exclude=refs/git-mem/*", "--all", *([f"-{a.n}"] if a.n else []), "--date=iso",
775
+ log = git("log", "--exclude=refs/git-sci/*", "--all", *([f"-{a.n}"] if a.n else []), "--date=iso",
776
776
  "--pretty=%H\x1f%h\x1f%P\x1f%D\x1f%ad\x1f%s")
777
777
  cols = ["hash", "short", "parents", "refs", "date", "subject"]
778
778
  commits = [dict(zip(cols, l.split("\x1f"))) for l in log.splitlines()]
779
779
  data = json.dumps({"commits": commits, "events": events()}).replace("</", "<\\/")
780
- out = a.output or os.path.join(git_mem_dir(), "viz.html")
780
+ out = a.output or os.path.join(git_sci_dir(), "viz.html")
781
781
  open(out, "w").write(VIZ.replace("__DATA__", data))
782
- print(f"git-mem: wrote {out}")
783
- VIZ = """<!doctype html><meta charset="utf-8"><title>git-mem</title>
782
+ print(f"git-sci: wrote {out}")
783
+ VIZ = """<!doctype html><meta charset="utf-8"><title>git-sci</title>
784
784
  <style>body{margin:0;font:12px/1.5 ui-monospace,Menlo,monospace;background:#fff;color:#000}
785
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
786
  main{display:grid;grid-template-columns:1fr 1fr;height:calc(100vh - 40px)}
@@ -789,7 +789,7 @@ main{display:grid;grid-template-columns:1fr 1fr;height:calc(100vh - 40px)}
789
789
  #c li:hover{background:#eee}#c li.sel{background:#000;color:#fff}
790
790
  #d{margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap}
791
791
  button,input{font:inherit;background:#fff;border:1px solid #000;accent-color:#000}</style>
792
- <header><b>git-mem</b><button id=play>replay</button><input type=range id=s min=0><span id=pos></span><span id=st></span></header>
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
793
  <main><ol id=c></ol><pre id=d>select a commit</pre></main>
794
794
  <script>const D=__DATA__,cs=D.commits,byC={};D.events.forEach(e=>(byC[e.commit]=byC[e.commit]||[]).push(e));
795
795
  const esc=t=>(t||'').replace(/&/g,'&amp;').replace(/</g,'&lt;');
@@ -803,21 +803,21 @@ function cut(){const n=+s.value;pos.textContent=n+'/'+cs.length;rows.forEach((li
803
803
  function detail(x){let o=`commit ${x.hash}\\n${x.date}${x.refs?' ('+x.refs+')':''}\\n${x.subject}\\n`;
804
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
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-mem operations recorded for this commit)';d.textContent=o;}
806
+ if(!(byC[x.hash]||[]).length)o+='\\n(no git-sci operations recorded for this commit)';d.textContent=o;}
807
807
  let t=null;play.onclick=()=>{if(t){clearInterval(t);t=null;return}s.value=0;cut();
808
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
809
  s.oninput=cut;cut();</script>
810
810
  """
811
811
  def main(argv=None):
812
- ap = argparse.ArgumentParser(prog="git-mem", description=__doc__)
812
+ ap = argparse.ArgumentParser(prog="git-sci", description=__doc__)
813
813
  sub = ap.add_subparsers(dest="cmd", required=True)
814
814
  ps = {c: sub.add_parser(c, help=h) for c, h in [
815
- ("init", "git repo + .git/git-mem + hooks + session import"), ("sync", "commit pending local edits"),
815
+ ("init", "git repo + .git/git-sci + hooks + session import"), ("sync", "commit pending local edits"),
816
816
  ("hook", "agent-harness hook entrypoint (JSON on stdin)"), ("show", "show operations for a commit"),
817
817
  ("import", "re-import sessions + backfill attribution"), ("gc", "drop session refs older than N days"),
818
- ("sessions", "list/read/export sessions in refs/git-mem/sessions"), ("log", "git log with actors + turns"),
818
+ ("sessions", "list/read/export sessions in refs/git-sci/sessions"), ("log", "git log with actors + turns"),
819
819
  ("undo", "undo one operation (default: latest) or a commit"), ("viz", "HTML visualization of the op log"),
820
- ("update", "update git-mem to the latest version"),
820
+ ("update", "update git-sci to the latest version"),
821
821
  ("export", "dump history as fine-tuning JSONL (prompt + tool_calls + edits per turn)"),
822
822
  ("blame", "the conversation behind a file:line")]}
823
823
  ps["init"].add_argument("--claude", action="store_true",
@@ -840,7 +840,7 @@ def main(argv=None):
840
840
  a = ap.parse_args(argv)
841
841
  if a.cmd not in ("init", "hook", "update"): # hook locates its repo from the payload
842
842
  if a.cmd == "blame": a.file = os.path.abspath(a.file) # resolve before the chdir
843
- os.chdir(root()); git_mem_dir(); a.lock = lock()
843
+ os.chdir(root()); git_sci_dir(); a.lock = lock()
844
844
  {"init": cmd_init, "hook": cmd_hook, "sync": cmd_sync, "log": cmd_log, "show": cmd_show,
845
845
  "undo": cmd_undo, "viz": cmd_viz, "import": cmd_import, "sessions": cmd_sessions,
846
846
  "gc": cmd_gc, "update": cmd_update, "export": cmd_export, "blame": cmd_blame}[a.cmd](a)
@@ -1,14 +1,14 @@
1
1
  [project]
2
2
  name = "git-sci"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "Share your team's memory - you, your agents, and your teammate's agents."
5
5
  requires-python = ">=3.9"
6
6
 
7
7
  [project.scripts]
8
- git-mem = "git_mem:main"
8
+ git-sci = "git_sci:main"
9
9
 
10
10
  [tool.flit.module]
11
- name = "git_mem"
11
+ name = "git_sci"
12
12
 
13
13
  [build-system]
14
14
  requires = ["flit_core>=3.2,<4"]