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.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/ujjalsharma100/agent-trace-cli/main/schemas/trace-record.schema.json",
|
|
4
|
+
"title": "TraceRecord",
|
|
5
|
+
"description": "One line in traces.jsonl. Schema 2.0: per-line full SHA-256 hashes give cryptographic uniqueness for deterministic attribution.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["version", "id", "timestamp", "tool", "files"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"version": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"enum": ["2.0"]
|
|
13
|
+
},
|
|
14
|
+
"id": { "type": "string", "minLength": 1 },
|
|
15
|
+
"timestamp": { "type": "string", "description": "ISO-8601 timestamp" },
|
|
16
|
+
"tool": { "$ref": "#/$defs/tool" },
|
|
17
|
+
"files": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"minItems": 1,
|
|
20
|
+
"items": { "$ref": "#/$defs/fileEntry" }
|
|
21
|
+
},
|
|
22
|
+
"vcs": { "$ref": "#/$defs/vcs" },
|
|
23
|
+
"metadata": { "$ref": "#/$defs/metadata" }
|
|
24
|
+
},
|
|
25
|
+
"$defs": {
|
|
26
|
+
"tool": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["name"],
|
|
29
|
+
"additionalProperties": true,
|
|
30
|
+
"properties": {
|
|
31
|
+
"name": { "type": "string" },
|
|
32
|
+
"version": { "type": "string" }
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"vcs": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"required": ["type", "revision"],
|
|
38
|
+
"additionalProperties": false,
|
|
39
|
+
"properties": {
|
|
40
|
+
"type": { "type": "string", "const": "git" },
|
|
41
|
+
"revision": { "type": "string", "minLength": 1 }
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"metadata": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": true,
|
|
47
|
+
"properties": {
|
|
48
|
+
"edit_sequence": { "type": "integer" },
|
|
49
|
+
"session_id": { "type": "string" },
|
|
50
|
+
"tool_name": { "type": "string" },
|
|
51
|
+
"tool_use_id": { "type": "string" },
|
|
52
|
+
"is_creation": { "type": "boolean" }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"lineHash": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["line_offset", "hash"],
|
|
58
|
+
"additionalProperties": false,
|
|
59
|
+
"properties": {
|
|
60
|
+
"line_offset": { "type": "integer", "minimum": 0 },
|
|
61
|
+
"hash": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"pattern": "^sha256:[0-9a-f]{64}$"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"range": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"required": ["start_line", "end_line"],
|
|
70
|
+
"additionalProperties": false,
|
|
71
|
+
"properties": {
|
|
72
|
+
"start_line": { "type": "integer", "minimum": 1 },
|
|
73
|
+
"end_line": { "type": "integer", "minimum": 1 },
|
|
74
|
+
"content_hash": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"pattern": "^sha256:[0-9a-f]{64}$"
|
|
77
|
+
},
|
|
78
|
+
"line_hashes": {
|
|
79
|
+
"type": "array",
|
|
80
|
+
"items": { "$ref": "#/$defs/lineHash" }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"contributor": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"required": ["type"],
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"properties": {
|
|
89
|
+
"type": { "type": "string" },
|
|
90
|
+
"model_id": { "type": "string" }
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"conversation": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"required": ["contributor", "ranges"],
|
|
96
|
+
"additionalProperties": false,
|
|
97
|
+
"properties": {
|
|
98
|
+
"contributor": { "$ref": "#/$defs/contributor" },
|
|
99
|
+
"ranges": {
|
|
100
|
+
"type": "array",
|
|
101
|
+
"minItems": 1,
|
|
102
|
+
"items": { "$ref": "#/$defs/range" }
|
|
103
|
+
},
|
|
104
|
+
"id": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
107
|
+
"description": "Opaque conversation identifier (sha256 over the original local transcript URL). Stable per-(machine, session); referenced everywhere transcripts are addressed."
|
|
108
|
+
},
|
|
109
|
+
"content_sha256": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
112
|
+
"description": "SHA-256 of the transcript bytes at the moment this trace was recorded. Resolves to a blob in the per-project content-addressed cache."
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"fileEntry": {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"required": ["path", "conversations"],
|
|
119
|
+
"additionalProperties": false,
|
|
120
|
+
"properties": {
|
|
121
|
+
"path": { "type": "string", "minLength": 1 },
|
|
122
|
+
"conversations": {
|
|
123
|
+
"type": "array",
|
|
124
|
+
"minItems": 1,
|
|
125
|
+
"items": { "$ref": "#/$defs/conversation" }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
agent_trace/session.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-session manifest (~/.agent-trace/sessions/<session_id>.json) — project
|
|
3
|
+
touch counts for cross-repo sessions (Phase 1b).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .storage import get_sessions_dir
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sessions_dir():
|
|
19
|
+
return get_sessions_dir()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _SessionLock:
|
|
23
|
+
def __init__(self, session_id: str) -> None:
|
|
24
|
+
self.session_id = session_id
|
|
25
|
+
self._fp: Any = None
|
|
26
|
+
|
|
27
|
+
def __enter__(self) -> None:
|
|
28
|
+
_sessions_dir().mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path = _sessions_dir() / f".{self.session_id}.lock"
|
|
30
|
+
self._fp = open(path, "a+")
|
|
31
|
+
if sys.platform != "win32":
|
|
32
|
+
import fcntl
|
|
33
|
+
|
|
34
|
+
fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(self, *args: object) -> None:
|
|
38
|
+
if self._fp:
|
|
39
|
+
if sys.platform != "win32":
|
|
40
|
+
import fcntl
|
|
41
|
+
|
|
42
|
+
fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
|
|
43
|
+
self._fp.close()
|
|
44
|
+
self._fp = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _atomic_write(path: os.PathLike[str], text: str) -> None:
|
|
48
|
+
p = os.fspath(path)
|
|
49
|
+
d = os.path.dirname(p)
|
|
50
|
+
os.makedirs(d, exist_ok=True)
|
|
51
|
+
fd, tmp = tempfile.mkstemp(prefix=".sess.", dir=d, text=True)
|
|
52
|
+
try:
|
|
53
|
+
with os.fdopen(fd, "w") as f:
|
|
54
|
+
f.write(text)
|
|
55
|
+
os.replace(tmp, p)
|
|
56
|
+
except Exception:
|
|
57
|
+
try:
|
|
58
|
+
os.unlink(tmp)
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def primary_project_id_for_session(session_id: str) -> str | None:
|
|
65
|
+
"""Project id that received the most trace touches this session (nested-repo safe).
|
|
66
|
+
|
|
67
|
+
Used so summary hooks attribute to the same project as file-based traces when
|
|
68
|
+
the IDE workspace cwd is a parent folder but edits targeted an inner git repo.
|
|
69
|
+
"""
|
|
70
|
+
if not session_id:
|
|
71
|
+
return None
|
|
72
|
+
path = _sessions_dir() / f"{session_id}.json"
|
|
73
|
+
if not path.is_file():
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(path.read_text())
|
|
77
|
+
except (json.JSONDecodeError, OSError):
|
|
78
|
+
return None
|
|
79
|
+
counts = data.get("edit_counts") if isinstance(data.get("edit_counts"), dict) else {}
|
|
80
|
+
if counts:
|
|
81
|
+
best: str | None = None
|
|
82
|
+
best_n = -1
|
|
83
|
+
for pid, n in counts.items():
|
|
84
|
+
if not isinstance(pid, str) or not pid:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
v = int(n)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
continue
|
|
90
|
+
if v > best_n:
|
|
91
|
+
best_n = v
|
|
92
|
+
best = pid
|
|
93
|
+
if best is not None:
|
|
94
|
+
return best
|
|
95
|
+
projects = data.get("projects") if isinstance(data.get("projects"), list) else []
|
|
96
|
+
if len(projects) == 1 and isinstance(projects[0], str) and projects[0]:
|
|
97
|
+
return projects[0]
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def touch_session_project(
|
|
102
|
+
session_id: str,
|
|
103
|
+
project_id: str,
|
|
104
|
+
*,
|
|
105
|
+
tool_name: str | None = None,
|
|
106
|
+
transcript_path: str | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Record that this session touched a project; bump edit counter."""
|
|
109
|
+
if not session_id or not project_id:
|
|
110
|
+
return
|
|
111
|
+
path = _sessions_dir() / f"{session_id}.json"
|
|
112
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
113
|
+
with _SessionLock(session_id):
|
|
114
|
+
data: dict[str, Any]
|
|
115
|
+
if path.is_file():
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(path.read_text())
|
|
118
|
+
except (json.JSONDecodeError, OSError):
|
|
119
|
+
data = {}
|
|
120
|
+
else:
|
|
121
|
+
data = {}
|
|
122
|
+
data.setdefault("session_id", session_id)
|
|
123
|
+
data.setdefault("started_at", now)
|
|
124
|
+
data["ended_at"] = None
|
|
125
|
+
if tool_name:
|
|
126
|
+
data.setdefault("tool", {})["name"] = tool_name
|
|
127
|
+
if transcript_path:
|
|
128
|
+
data["transcript_path"] = transcript_path
|
|
129
|
+
projects: list[str] = list(data.get("projects") or [])
|
|
130
|
+
if project_id not in projects:
|
|
131
|
+
projects.append(project_id)
|
|
132
|
+
data["projects"] = projects
|
|
133
|
+
counts: dict[str, int] = dict(data.get("edit_counts") or {})
|
|
134
|
+
counts[project_id] = int(counts.get(project_id, 0)) + 1
|
|
135
|
+
data["edit_counts"] = counts
|
|
136
|
+
_atomic_write(path, json.dumps(data, indent=2) + "\n")
|
agent_trace/storage.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Central path resolution for agent-trace.
|
|
3
|
+
|
|
4
|
+
All per-project runtime data lives under ``<AGENT_TRACE_HOME>/projects/<project_id>/``.
|
|
5
|
+
|
|
6
|
+
``project_id`` is anchored to the repo's ``.git`` directory: a single-line
|
|
7
|
+
``agent-trace-id`` file under ``git rev-parse --git-common-dir``. Worktrees
|
|
8
|
+
of the same repo share the anchor (and therefore the data directory), and a
|
|
9
|
+
rename of the repo on disk preserves the id since the file lives inside
|
|
10
|
+
``.git``. Re-clones legitimately get a fresh id — fresh clones rely on
|
|
11
|
+
``refs/notes/agent-trace`` for attribution, not on local data.
|
|
12
|
+
|
|
13
|
+
When the anchor doesn't exist yet, we fall back to a path-derived id so
|
|
14
|
+
``status`` / ``blame`` from un-init'd repos still resolve to *something*.
|
|
15
|
+
``init`` and any other ``create=True`` resolution will write the anchor.
|
|
16
|
+
|
|
17
|
+
``AGENT_TRACE_HOME`` overrides the default ``~/.agent-trace`` (used by tests).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
import uuid
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_agent_trace_home() -> Path:
|
|
29
|
+
"""Root of all global agent-trace state (respects ``AGENT_TRACE_HOME``)."""
|
|
30
|
+
env = os.environ.get("AGENT_TRACE_HOME")
|
|
31
|
+
if env:
|
|
32
|
+
return Path(env).expanduser()
|
|
33
|
+
return Path.home() / ".agent-trace"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_global_config_file() -> Path:
|
|
37
|
+
return get_agent_trace_home() / "config.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_projects_registry_file() -> Path:
|
|
41
|
+
return get_agent_trace_home() / "projects.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_sessions_dir() -> Path:
|
|
45
|
+
return get_agent_trace_home() / "sessions"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_detached_base_dir() -> Path:
|
|
49
|
+
return get_agent_trace_home() / "detached"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _sanitize_id(project_id: str) -> str:
|
|
53
|
+
"""Defensive sanitization — path-derived ids are already dash-only,
|
|
54
|
+
but anything unexpected (``:``, ``\\``, residual ``/``) gets flattened."""
|
|
55
|
+
return project_id.replace(":", "_").replace("/", "-").replace("\\", "-")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def path_to_project_id(repo_root: str) -> str:
|
|
59
|
+
"""Derive a project_id from an absolute repo path (fallback only).
|
|
60
|
+
|
|
61
|
+
``/Users/jane/Desktop/foo`` → ``-Users-jane-Desktop-foo`` (Claude-Code convention).
|
|
62
|
+
Used when no ``.git/agent-trace-id`` anchor exists. Once a project is
|
|
63
|
+
initialised, the anchor takes over and the path is no longer load-bearing.
|
|
64
|
+
"""
|
|
65
|
+
canon = os.path.realpath(repo_root)
|
|
66
|
+
return canon.replace(os.sep, "-")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# -------------------------------------------------------------------
|
|
70
|
+
# Anchor (.git/agent-trace-id) — stable, worktree-aware project identity
|
|
71
|
+
# -------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
ANCHOR_FILENAME = "agent-trace-id"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _git_common_dir(repo_dir: str) -> str | None:
|
|
77
|
+
"""Return the shared ``.git`` directory for ``repo_dir``.
|
|
78
|
+
|
|
79
|
+
For ordinary repos this is ``<repo>/.git``. For linked worktrees,
|
|
80
|
+
``--git-common-dir`` resolves to the main repo's ``.git`` so all
|
|
81
|
+
worktrees of the same repo share the anchor (and therefore the
|
|
82
|
+
project_id).
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
r = subprocess.run(
|
|
86
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
87
|
+
cwd=repo_dir, capture_output=True, text=True, timeout=10,
|
|
88
|
+
)
|
|
89
|
+
if r.returncode != 0:
|
|
90
|
+
return None
|
|
91
|
+
p = r.stdout.strip()
|
|
92
|
+
if not p:
|
|
93
|
+
return None
|
|
94
|
+
if not os.path.isabs(p):
|
|
95
|
+
p = os.path.join(os.path.realpath(repo_dir), p)
|
|
96
|
+
return os.path.realpath(p)
|
|
97
|
+
except Exception:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _anchor_path(git_common_dir: str) -> Path:
|
|
102
|
+
return Path(git_common_dir) / ANCHOR_FILENAME
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _read_anchor(git_common_dir: str) -> str | None:
|
|
106
|
+
p = _anchor_path(git_common_dir)
|
|
107
|
+
try:
|
|
108
|
+
if not p.is_file():
|
|
109
|
+
return None
|
|
110
|
+
s = p.read_text().strip()
|
|
111
|
+
return s or None
|
|
112
|
+
except OSError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _generate_project_id() -> str:
|
|
117
|
+
"""Fresh opaque project_id. ``at-<32 hex>``; safe as a directory name."""
|
|
118
|
+
return f"at-{uuid.uuid4().hex}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_anchor(git_common_dir: str, project_id: str) -> bool:
|
|
122
|
+
try:
|
|
123
|
+
p = _anchor_path(git_common_dir)
|
|
124
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
p.write_text(project_id + "\n")
|
|
126
|
+
return True
|
|
127
|
+
except OSError:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_project_dir(project_id: str) -> Path:
|
|
132
|
+
return get_agent_trace_home() / "projects" / _sanitize_id(project_id)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def ensure_project_dir(project_id: str) -> Path:
|
|
136
|
+
d = get_project_dir(project_id)
|
|
137
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
return d
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# -------------------------------------------------------------------
|
|
142
|
+
# Per-project file paths
|
|
143
|
+
# -------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def get_traces_path(project_id: str) -> Path:
|
|
146
|
+
return get_project_dir(project_id) / "traces.jsonl"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_ledgers_path(project_id: str) -> Path:
|
|
150
|
+
return get_project_dir(project_id) / "ledgers.jsonl"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_commit_links_path(project_id: str) -> Path:
|
|
154
|
+
return get_project_dir(project_id) / "commit-links.jsonl"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_session_state_path(project_id: str) -> Path:
|
|
158
|
+
return get_project_dir(project_id) / "session-state.json"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_session_summaries_path(project_id: str) -> Path:
|
|
162
|
+
"""Append-only JSONL of per-session LLM summaries."""
|
|
163
|
+
return get_project_dir(project_id) / "session-summaries.jsonl"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_project_config_path(project_id: str) -> Path:
|
|
167
|
+
return get_project_dir(project_id) / "project-config.json"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_attribution_state_path(project_id: str) -> Path:
|
|
171
|
+
"""Per-project attribution-window cursor (last commit timestamp seen)."""
|
|
172
|
+
return get_project_dir(project_id) / "attribution-state.json"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# -------------------------------------------------------------------
|
|
176
|
+
# Repo-dir → project_id resolution
|
|
177
|
+
# -------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def resolve_project_id(
|
|
180
|
+
repo_dir: str | os.PathLike[str] | None,
|
|
181
|
+
*,
|
|
182
|
+
create: bool = False,
|
|
183
|
+
) -> str | None:
|
|
184
|
+
"""Resolve a stable ``project_id`` for a repo directory.
|
|
185
|
+
|
|
186
|
+
Resolution order:
|
|
187
|
+
1. ``.git/agent-trace-id`` anchor (shared across worktrees of the
|
|
188
|
+
same repo). Survives ``mv`` of the repo on disk.
|
|
189
|
+
2. With ``create=True`` and no anchor present: generate a fresh
|
|
190
|
+
opaque id (``at-<32 hex>``) and write the anchor.
|
|
191
|
+
3. Path-derived fallback (``/Users/x/foo`` → ``-Users-x-foo``) for
|
|
192
|
+
un-init'd repos and non-git directories.
|
|
193
|
+
|
|
194
|
+
When ``create`` is True the registry also gets a metadata entry.
|
|
195
|
+
"""
|
|
196
|
+
if repo_dir is None:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
from .trace import git_repo_root_for_path
|
|
200
|
+
|
|
201
|
+
root = git_repo_root_for_path(str(repo_dir))
|
|
202
|
+
|
|
203
|
+
if root:
|
|
204
|
+
# Inside a git repo — try the anchor.
|
|
205
|
+
common = _git_common_dir(root) or os.path.join(root, ".git")
|
|
206
|
+
pid = _read_anchor(common)
|
|
207
|
+
if pid is None and create:
|
|
208
|
+
pid = _generate_project_id()
|
|
209
|
+
_write_anchor(common, pid)
|
|
210
|
+
if pid is None:
|
|
211
|
+
# Un-init'd repo: behave like the legacy path-derived form so
|
|
212
|
+
# status/blame from a fresh checkout still resolves.
|
|
213
|
+
pid = path_to_project_id(root)
|
|
214
|
+
|
|
215
|
+
if create:
|
|
216
|
+
from .registry import register_project_metadata
|
|
217
|
+
|
|
218
|
+
register_project_metadata(root, pid)
|
|
219
|
+
return pid
|
|
220
|
+
|
|
221
|
+
# Not inside a git repo at all — keep the path-derived id (used by
|
|
222
|
+
# detached-edit handling and tests for non-repo paths).
|
|
223
|
+
real = os.path.realpath(str(repo_dir))
|
|
224
|
+
pid = path_to_project_id(real)
|
|
225
|
+
if create:
|
|
226
|
+
from .registry import register_project_metadata
|
|
227
|
+
|
|
228
|
+
register_project_metadata(real, pid)
|
|
229
|
+
return pid
|