agent-trace-cli 0.1.0__py3-none-any.whl

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.
Files changed (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
agent_trace/record.py ADDED
@@ -0,0 +1,417 @@
1
+ """
2
+ Trace recording — reads hook events from stdin, dispatches them to the
3
+ right adapter's translator, and stores the resulting trace locally.
4
+
5
+ This module is **agent-agnostic**: it knows nothing about Cursor,
6
+ Claude, Codex, or any future harness. The dispatcher walks the adapter
7
+ registry — each adapter declares which hook event names it owns
8
+ (``adapter.EVENTS``), which env vars hold the transcript path
9
+ (``transcript_env_vars``), which env vars hold the workspace dir
10
+ (``project_dir_env_vars``), and which events should fire the summary
11
+ command (``summary_only_events`` / ``summary_then_trace_events``).
12
+
13
+ What stays here are *generic* helpers used by every adapter's
14
+ translators: edit-sequence tracking, file IO, range computation, the
15
+ final ``_store_local`` writer.
16
+
17
+ Hooks write *only* to local JSONL. Network calls happen exclusively via
18
+ ``sync.py`` (``agent-trace push`` / ``agent-trace pull``).
19
+
20
+ No external dependencies — uses only the Python standard library.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ from .config import get_project_config
31
+ from .session import touch_session_project
32
+ from .storage import (
33
+ ensure_project_dir,
34
+ get_session_state_path,
35
+ get_traces_path,
36
+ resolve_project_id,
37
+ )
38
+ from .trace import (
39
+ compute_range_positions,
40
+ create_trace,
41
+ get_workspace_root,
42
+ resolve_file_project,
43
+ )
44
+
45
+
46
+ # -------------------------------------------------------------------
47
+ # Hook payload helpers (registry-driven)
48
+ # -------------------------------------------------------------------
49
+
50
+ def transcript_path_from_hook(data: dict) -> str | None:
51
+ """Resolve the conversation transcript path from a hook payload.
52
+
53
+ Order of precedence:
54
+ 1. ``data["transcript_path"]`` (Cursor sets this on most hooks)
55
+ 2. The first hit across every adapter's ``transcript_env_vars``
56
+ (e.g. Cursor sets ``CURSOR_TRANSCRIPT_PATH`` for ``sessionEnd``
57
+ / ``Stop`` where the JSON omits the path).
58
+
59
+ New harnesses contribute their own env-var name on the adapter —
60
+ this function never names a tool.
61
+ """
62
+ tp = data.get("transcript_path")
63
+ if isinstance(tp, str) and tp.strip():
64
+ return tp.strip()
65
+ from .hooks import iter_adapters
66
+
67
+ for adapter in iter_adapters():
68
+ for var in getattr(adapter, "transcript_env_vars", ()) or ():
69
+ v = os.environ.get(var)
70
+ if isinstance(v, str) and v.strip():
71
+ return v.strip()
72
+ return None
73
+
74
+
75
+ def project_dir_from_hook(data: dict) -> str:
76
+ """Resolve the workspace dir from a hook payload.
77
+
78
+ Order: ``data["cwd"]`` → adapter-declared env vars (e.g.
79
+ ``CURSOR_PROJECT_DIR``, ``CLAUDE_PROJECT_DIR``) →
80
+ ``data["workspace_roots"][0]`` → ``os.getcwd()``.
81
+ """
82
+ cwd = data.get("cwd")
83
+ if isinstance(cwd, str) and cwd.strip():
84
+ return cwd.strip()
85
+ from .hooks import iter_adapters
86
+
87
+ for adapter in iter_adapters():
88
+ for var in getattr(adapter, "project_dir_env_vars", ()) or ():
89
+ v = os.environ.get(var)
90
+ if isinstance(v, str) and v.strip():
91
+ return v.strip()
92
+ roots = data.get("workspace_roots")
93
+ if isinstance(roots, list) and roots:
94
+ r0 = roots[0]
95
+ if isinstance(r0, str) and r0.strip():
96
+ return r0.strip()
97
+ return os.getcwd()
98
+
99
+
100
+ def snapshot_transcript_on_session_end(data: dict) -> None:
101
+ """Snapshot the live transcript bytes into the per-project cache.
102
+
103
+ Runs on every session-end / agent-stop event independent of summary
104
+ config. ``PostToolUse`` snapshots only ever capture transcript state up
105
+ to a tool call; this is the only event that captures the conversation
106
+ tail after the agent's final response. Never raises.
107
+ """
108
+ try:
109
+ from .conversations import snapshot_transcript_to_cache
110
+ from .storage import resolve_project_id
111
+
112
+ cwd = project_dir_for_summary_hook(data)
113
+ pid = resolve_project_id(cwd, create=False)
114
+ if not pid:
115
+ return
116
+ transcript_path = transcript_path_from_hook(data)
117
+ if not transcript_path:
118
+ return
119
+ snapshot_transcript_to_cache(pid, transcript_path)
120
+ except Exception:
121
+ pass
122
+
123
+
124
+ def project_dir_for_summary_hook(data: dict) -> str:
125
+ """Git root for summary config and storage: prefer session's primary touched repo.
126
+
127
+ File traces resolve the repo from edited paths (nested repos → inner root). Hooks
128
+ often pass only workspace ``cwd``, which may be a parent folder whose git root is
129
+ the outer monorepo — wrong ``project_id``. Session manifests record which
130
+ ``project_id`` actually received edits; match that when possible.
131
+ """
132
+ from .registry import get_project_record
133
+ from .session import primary_project_id_for_session
134
+
135
+ sid = str(
136
+ data.get("conversation_id") or data.get("session_id") or "",
137
+ ).strip()
138
+ if sid:
139
+ pid = primary_project_id_for_session(sid)
140
+ if pid and not pid.startswith("detached:"):
141
+ rec = get_project_record(pid)
142
+ if rec:
143
+ root = rec.get("canonical_root")
144
+ if isinstance(root, str) and root.strip() and os.path.isdir(root.strip()):
145
+ return os.path.realpath(root.strip())
146
+ return project_dir_from_hook(data)
147
+
148
+
149
+ # -------------------------------------------------------------------
150
+ # Session edit sequence tracking (shared across all adapters)
151
+ # -------------------------------------------------------------------
152
+
153
+ def _get_next_sequence(session_id: str, project_dir: str | None = None) -> int:
154
+ """Return the next edit sequence number for a session, incrementing atomically.
155
+
156
+ State lives in ``<AGENT_TRACE_HOME>/projects/<id>/session-state.json``
157
+ as ``{"seq:<session_id>": N}``.
158
+ """
159
+ if not session_id:
160
+ return 0
161
+ if project_dir is None:
162
+ project_dir = get_workspace_root()
163
+ pid = resolve_project_id(project_dir, create=True)
164
+ if not pid:
165
+ return 0
166
+ ensure_project_dir(pid)
167
+ state_path = get_session_state_path(pid)
168
+ state_path.parent.mkdir(parents=True, exist_ok=True)
169
+
170
+ state: dict = {}
171
+ if state_path.exists():
172
+ try:
173
+ state = json.loads(state_path.read_text())
174
+ except (json.JSONDecodeError, OSError):
175
+ state = {}
176
+
177
+ key = f"seq:{session_id}"
178
+ seq = state.get(key, 0)
179
+ state[key] = seq + 1
180
+
181
+ try:
182
+ state_path.write_text(json.dumps(state))
183
+ except OSError:
184
+ pass
185
+
186
+ return seq
187
+
188
+
189
+ # -------------------------------------------------------------------
190
+ # File / range helpers (shared)
191
+ # -------------------------------------------------------------------
192
+
193
+ def _try_read_file(path):
194
+ try:
195
+ with open(path) as f:
196
+ return f.read()
197
+ except Exception:
198
+ return None
199
+
200
+
201
+ def _file_existed_before(file_path: str) -> bool:
202
+ try:
203
+ return Path(file_path).exists()
204
+ except OSError:
205
+ return False
206
+
207
+
208
+ def _ranges_from_write(_file_path: str, content: str) -> tuple[list | None, list | None]:
209
+ """For Write tool: entire file is the new content."""
210
+ if not content:
211
+ return None, None
212
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n")
213
+ line_count = normalized.count("\n") + (0 if normalized.endswith("\n") else 1)
214
+ if line_count <= 0:
215
+ return None, None
216
+ rp = [{"start_line": 1, "end_line": line_count}]
217
+ rc = [content]
218
+ return rp, rc
219
+
220
+
221
+ def _ranges_from_edit(file_path: str, old_string: str, new_string: str) -> tuple[list | None, list | None]:
222
+ """For Edit tool: single old/new pair."""
223
+ if not new_string:
224
+ return None, None
225
+ edits = [{"old_string": old_string, "new_string": new_string}]
226
+ fc = _try_read_file(file_path) if file_path else None
227
+ rp = compute_range_positions(edits, fc)
228
+ rc = [new_string]
229
+ return rp, rc
230
+
231
+
232
+ def _ranges_from_multiedit(file_path: str, edits: list) -> tuple[list | None, list | None]:
233
+ """For MultiEdit: replay edits in order, emit one range per edit."""
234
+ if not edits:
235
+ return None, None
236
+ buffer = _try_read_file(file_path) or ""
237
+ rp: list[dict] = []
238
+ rc: list[str] = []
239
+ for edit in edits:
240
+ if not isinstance(edit, dict):
241
+ continue
242
+ old = edit.get("old_string", "")
243
+ new = edit.get("new_string", "")
244
+ if not new:
245
+ continue
246
+ if old == "":
247
+ start = 1
248
+ end = new.count("\n") + 1
249
+ else:
250
+ idx = buffer.find(old)
251
+ if idx < 0:
252
+ continue
253
+ start = buffer[:idx].count("\n") + 1
254
+ end = start + new.count("\n")
255
+ rp.append({"start_line": start, "end_line": end})
256
+ rc.append(new)
257
+ buffer = buffer.replace(old, new, 1) if old else new
258
+ return (rp if rp else None), (rc if rc else None)
259
+
260
+
261
+ def _ranges_from_notebook(_notebook_path: str, ti: dict) -> tuple[list | None, list | None]:
262
+ """For NotebookEdit: cell source as content; line numbers are cell-local."""
263
+ new_source = ti.get("new_source", "")
264
+ if not new_source:
265
+ return None, None
266
+ line_count = new_source.count("\n") + 1
267
+ rp = [{"start_line": 1, "end_line": line_count}]
268
+ rc = [new_source]
269
+ return rp, rc
270
+
271
+
272
+ # -------------------------------------------------------------------
273
+ # Storage backend
274
+ # -------------------------------------------------------------------
275
+
276
+ def _store_local(trace, project_dir=None):
277
+ """Append trace to <AGENT_TRACE_HOME>/projects/<id>/traces.jsonl."""
278
+ meta = trace.get("metadata") or {}
279
+ pid = meta.get("project_id")
280
+ if not pid:
281
+ if project_dir is None:
282
+ project_dir = meta.get("repo_root") or get_workspace_root()
283
+ pid = resolve_project_id(project_dir, create=True)
284
+ if not pid:
285
+ return
286
+ ensure_project_dir(pid)
287
+ path = get_traces_path(pid)
288
+ with open(path, "a") as f:
289
+ f.write(json.dumps(trace) + "\n")
290
+
291
+
292
+ # -------------------------------------------------------------------
293
+ # Back-compat re-exports
294
+ # -------------------------------------------------------------------
295
+ #
296
+ # Older code (and tests) imports translator functions directly from
297
+ # ``agent_trace.record``. The translators now live next to their
298
+ # adapter, but we keep stable aliases here so nothing breaks.
299
+
300
+ from .hooks.claude import ( # noqa: E402
301
+ _PostToolUse as _claude_PostToolUse,
302
+ _SessionEnd as _claude_SessionEnd,
303
+ _SessionStart as _claude_SessionStart,
304
+ _model_from_transcript_tail as _model_from_claude_transcript_tail,
305
+ )
306
+ from .hooks.codex import ( # noqa: E402
307
+ _SessionStart as _codex_SessionStart,
308
+ _TurnComplete as _codex_TurnComplete,
309
+ )
310
+ from .hooks.cursor import ( # noqa: E402
311
+ _afterFileEdit as _cursor_afterFileEdit,
312
+ _afterShellExecution as _cursor_afterShellExecution,
313
+ _afterTabFileEdit as _cursor_afterTabFileEdit,
314
+ _sessionEnd as _cursor_sessionEnd,
315
+ _sessionStart as _cursor_sessionStart,
316
+ )
317
+
318
+
319
+ # -------------------------------------------------------------------
320
+ # Main entry point
321
+ # -------------------------------------------------------------------
322
+
323
+ def record_from_stdin():
324
+ """Read a hook event from stdin, build a trace, and store it locally.
325
+
326
+ Hooks write *only* to local JSONL. Network sync happens via
327
+ ``agent-trace push`` / ``agent-trace pull``.
328
+
329
+ Dispatch is fully registry-driven:
330
+ 1. The event-name to translator mapping comes from each adapter's
331
+ ``EVENTS`` dict.
332
+ 2. Whether the event triggers the summary command — and whether
333
+ it should *also* be dispatched as a regular trace — comes from
334
+ each adapter's ``summary_only_events`` /
335
+ ``summary_then_trace_events`` declarations.
336
+ 3. Each adapter optionally runs ``pre_summary_hook`` before the
337
+ summary command (e.g. Claude refreshes its model cache).
338
+ """
339
+ raw = sys.stdin.read().strip()
340
+ if not raw:
341
+ return
342
+
343
+ try:
344
+ data = json.loads(raw)
345
+ except json.JSONDecodeError:
346
+ return
347
+
348
+ event = data.get("hook_event_name", "") or ""
349
+
350
+ from .hooks import iter_adapters
351
+
352
+ # Phase 1: session-end / summary classification.
353
+ summary_owner = None
354
+ also_dispatch = False
355
+ for adapter in iter_adapters():
356
+ triggers, dispatch = adapter.is_session_end(event)
357
+ if triggers:
358
+ summary_owner = adapter
359
+ also_dispatch = dispatch
360
+ break
361
+
362
+ if summary_owner is not None:
363
+ try:
364
+ summary_owner.pre_summary_hook(data)
365
+ except Exception:
366
+ pass
367
+ # Capture the transcript tail into the cache before summary runs,
368
+ # so it lands even when summaries are disabled.
369
+ snapshot_transcript_on_session_end(data)
370
+ try:
371
+ from .summary import run_session_summary_hook
372
+
373
+ run_session_summary_hook(data)
374
+ except Exception:
375
+ pass
376
+ if not also_dispatch:
377
+ return
378
+ # Fall through so the event also reaches its adapter's
379
+ # translator (e.g. Cursor's ``sessionEnd`` records a trace).
380
+
381
+ # Phase 2: trace handler dispatch via the registry.
382
+ handler = None
383
+ for adapter in iter_adapters():
384
+ events = getattr(adapter, "EVENTS", None) or {}
385
+ if event in events:
386
+ handler = events[event]
387
+ break
388
+ if handler is None:
389
+ return
390
+
391
+ trace, _hook_event = handler(data)
392
+ if trace is None:
393
+ return
394
+
395
+ meta = trace.get("metadata") or {}
396
+ repo_root = meta.get("repo_root")
397
+ config = get_project_config(project_dir=repo_root) if repo_root else get_project_config()
398
+ if config is None:
399
+ return
400
+
401
+ sid = (
402
+ meta.get("session_id")
403
+ or meta.get("conversation_id")
404
+ or data.get("conversation_id")
405
+ or data.get("session_id")
406
+ )
407
+ pid = meta.get("project_id")
408
+ if sid and pid:
409
+ tool = trace.get("tool") if isinstance(trace.get("tool"), dict) else {}
410
+ touch_session_project(
411
+ str(sid),
412
+ str(pid),
413
+ tool_name=(tool or {}).get("name"),
414
+ transcript_path=transcript_path_from_hook(data),
415
+ )
416
+
417
+ _store_local(trace, project_dir=repo_root)
@@ -0,0 +1,230 @@
1
+ """
2
+ Project metadata registry (``<AGENT_TRACE_HOME>/projects.json``).
3
+
4
+ ``project_id`` is now deterministic — it's the repo's canonical absolute path
5
+ with ``/`` replaced by ``-`` (see ``storage.path_to_project_id``). The registry
6
+ keeps per-project metadata (first commit sha, origin URL, known_roots) so
7
+ ``agent-trace projects`` can show human context and so tests / tools can cross
8
+ reference past repo locations.
9
+
10
+ POSIX: fcntl advisory lock during read-modify-write. Other platforms: best-effort.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ from typing import Any
21
+
22
+ from .storage import (
23
+ get_agent_trace_home,
24
+ get_projects_registry_file,
25
+ path_to_project_id,
26
+ )
27
+
28
+
29
+ def __getattr__(name: str):
30
+ if name == "PROJECTS_FILE":
31
+ return get_projects_registry_file()
32
+ raise AttributeError(name)
33
+
34
+
35
+ def _projects_file() -> os.PathLike:
36
+ """Resolve the projects registry file, honoring test-time patches."""
37
+ import sys
38
+ mod = sys.modules[__name__]
39
+ if "PROJECTS_FILE" in mod.__dict__:
40
+ return mod.__dict__["PROJECTS_FILE"]
41
+ return get_projects_registry_file()
42
+
43
+
44
+ def _git(args: list[str], cwd: str, timeout: float = 15.0) -> str | None:
45
+ import subprocess
46
+
47
+ try:
48
+ r = subprocess.run(
49
+ ["git", *args],
50
+ capture_output=True,
51
+ text=True,
52
+ cwd=cwd,
53
+ timeout=timeout,
54
+ )
55
+ if r.returncode == 0:
56
+ return r.stdout.strip()
57
+ except (OSError, subprocess.TimeoutExpired):
58
+ pass
59
+ return None
60
+
61
+
62
+ def _first_commit_sha(repo_root: str) -> str | None:
63
+ out = _git(["log", "--reverse", "--format=%H", "--max-parents=0"], repo_root)
64
+ if not out:
65
+ return None
66
+ line = out.split("\n", 1)[0].strip()
67
+ return line or None
68
+
69
+
70
+ def _origin_url(repo_root: str) -> str | None:
71
+ u = _git(["config", "--get", "remote.origin.url"], repo_root)
72
+ return u or None
73
+
74
+
75
+ def compute_project_identity(repo_root: str) -> dict[str, Any]:
76
+ root = os.path.realpath(repo_root)
77
+ return {
78
+ "first_commit_sha": _first_commit_sha(root),
79
+ "origin_url": _origin_url(root),
80
+ "canonical_root": root,
81
+ }
82
+
83
+
84
+ def _empty_registry() -> dict[str, Any]:
85
+ return {"version": 2, "projects": {}}
86
+
87
+
88
+ def _load_raw() -> dict[str, Any]:
89
+ if not _projects_file().is_file():
90
+ return _empty_registry()
91
+ try:
92
+ data = json.loads(_projects_file().read_text())
93
+ if not isinstance(data, dict):
94
+ return _empty_registry()
95
+ data.setdefault("projects", {})
96
+ return data
97
+ except (json.JSONDecodeError, OSError):
98
+ return _empty_registry()
99
+
100
+
101
+ def _atomic_write(path: os.PathLike[str], text: str) -> None:
102
+ p = os.fspath(path)
103
+ d = os.path.dirname(p)
104
+ os.makedirs(d, exist_ok=True)
105
+ fd, tmp = tempfile.mkstemp(prefix=".projects.", dir=d, text=True)
106
+ try:
107
+ with os.fdopen(fd, "w") as f:
108
+ f.write(text)
109
+ os.replace(tmp, p)
110
+ except Exception:
111
+ try:
112
+ os.unlink(tmp)
113
+ except OSError:
114
+ pass
115
+ raise
116
+
117
+
118
+ class _RegistryLock:
119
+ def __init__(self) -> None:
120
+ self._fp: Any = None
121
+
122
+ def __enter__(self) -> None:
123
+ home = get_agent_trace_home()
124
+ home.mkdir(parents=True, exist_ok=True)
125
+ lock_path = home / ".registry.lock"
126
+ self._fp = open(lock_path, "a+")
127
+ if sys.platform != "win32":
128
+ import fcntl
129
+
130
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
131
+ return self
132
+
133
+ def __exit__(self, *args: object) -> None:
134
+ if self._fp:
135
+ if sys.platform != "win32":
136
+ import fcntl
137
+
138
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
139
+ self._fp.close()
140
+ self._fp = None
141
+
142
+
143
+ def register_project_metadata(repo_root: str, project_id: str | None = None) -> str:
144
+ """Upsert metadata for a repo. Returns the anchor-aware project_id.
145
+
146
+ When ``project_id`` is not supplied, resolves it via the same
147
+ anchor-aware path used everywhere else (``storage.resolve_project_id``)
148
+ so the recorder, ``init``, ``blame``, and the registry all agree on
149
+ the same id. Without this, a fresh ``.git/agent-trace-id`` anchor
150
+ would be ignored and the registry would fall back to the path-derived
151
+ id, leaving the recorder writing under one id while ``init`` wrote
152
+ config under another.
153
+ """
154
+ canon = os.path.realpath(repo_root)
155
+ if project_id is None:
156
+ from .storage import resolve_project_id
157
+
158
+ resolved = resolve_project_id(canon, create=True)
159
+ pid = resolved or path_to_project_id(canon)
160
+ else:
161
+ pid = project_id
162
+ fc = _first_commit_sha(canon)
163
+ origin = _origin_url(canon)
164
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
165
+
166
+ with _RegistryLock():
167
+ data = _load_raw()
168
+ projects: dict[str, Any] = data["projects"]
169
+ rec = projects.get(pid)
170
+ if rec is None:
171
+ rec = {
172
+ "first_commit_sha": fc,
173
+ "origin_url": origin,
174
+ "canonical_root": canon,
175
+ "known_roots": [canon],
176
+ "created_at": now,
177
+ }
178
+ projects[pid] = rec
179
+ else:
180
+ roots = rec.setdefault("known_roots", [])
181
+ if canon not in roots:
182
+ roots.append(canon)
183
+ rec["canonical_root"] = canon
184
+ if fc:
185
+ rec["first_commit_sha"] = fc
186
+ if origin:
187
+ rec["origin_url"] = origin
188
+ _atomic_write(_projects_file(), json.dumps(data, indent=2) + "\n")
189
+ return pid
190
+
191
+
192
+ def lookup_or_create_project_id(repo_root: str) -> str:
193
+ """Return the deterministic project_id for a repo and record its metadata."""
194
+ return register_project_metadata(repo_root)
195
+
196
+
197
+ def get_project_record(project_id: str) -> dict[str, Any] | None:
198
+ data = _load_raw()
199
+ rec = data["projects"].get(project_id)
200
+ return dict(rec) if isinstance(rec, dict) else None
201
+
202
+
203
+ def register_known_root(project_id: str, root: str) -> None:
204
+ canon = os.path.realpath(root)
205
+ with _RegistryLock():
206
+ data = _load_raw()
207
+ projects: dict[str, Any] = data["projects"]
208
+ if project_id not in projects:
209
+ return
210
+ rec = projects[project_id]
211
+ roots = rec.setdefault("known_roots", [])
212
+ if canon not in roots:
213
+ roots.append(canon)
214
+ _atomic_write(_projects_file(), json.dumps(data, indent=2) + "\n")
215
+
216
+
217
+ def list_projects() -> list[dict[str, Any]]:
218
+ data = _load_raw()
219
+ out: list[dict[str, Any]] = []
220
+ for pid, rec in data.get("projects", {}).items():
221
+ if isinstance(rec, dict):
222
+ out.append({"project_id": pid, **rec})
223
+ out.sort(key=lambda x: x.get("created_at") or "")
224
+ return out
225
+
226
+
227
+ def lookup_project_id_by_path(repo_root: str) -> str | None:
228
+ """Return the deterministic id for this path, regardless of registry state."""
229
+ canon = os.path.realpath(repo_root)
230
+ return path_to_project_id(canon)