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,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Codex CLI adapter — hooks injection, event translators, summary
|
|
3
|
+
lifecycle.
|
|
4
|
+
|
|
5
|
+
OpenAI's Codex CLI exposes a single hook surface called ``notify``: a
|
|
6
|
+
program declared in ``~/.codex/config.toml`` which Codex spawns when an
|
|
7
|
+
agent turn completes. The program receives a JSON payload as its last
|
|
8
|
+
argv, e.g.::
|
|
9
|
+
|
|
10
|
+
{"type": "agent-turn-complete", "turn-id": "...",
|
|
11
|
+
"input-messages": [...], "last-assistant-message": "..."}
|
|
12
|
+
|
|
13
|
+
Strategy:
|
|
14
|
+
|
|
15
|
+
- ``inject`` writes a ``notify`` entry that pipes Codex's JSON through
|
|
16
|
+
a tiny shell wrapper into ``agent-trace record``. The wrapper also
|
|
17
|
+
annotates the event with a canonical ``hook_event_name`` so the
|
|
18
|
+
record dispatcher knows which adapter owns it.
|
|
19
|
+
- The canonical event names this adapter emits:
|
|
20
|
+
* ``CodexTurnComplete`` — one trace per finished agent turn
|
|
21
|
+
* ``CodexSessionStart`` — set-up hook (optional, written when
|
|
22
|
+
Codex's config supports it; Codex doesn't currently expose
|
|
23
|
+
a session-start hook, so this event is here so tests and
|
|
24
|
+
future Codex versions can drive it via stdin).
|
|
25
|
+
- For tests / fixtures, simply pipe a JSON object with
|
|
26
|
+
``"hook_event_name": "CodexTurnComplete"`` into ``agent-trace
|
|
27
|
+
record`` (mirrors the Cursor / Claude testing pattern).
|
|
28
|
+
|
|
29
|
+
To add a new coding agent, copy this file, swap the config-path /
|
|
30
|
+
shell-wrapper bits, declare your event names and translators, and
|
|
31
|
+
register the adapter in ``hooks/__init__.py``. The CLI, doctor, status
|
|
32
|
+
and rule surfaces will pick it up automatically.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from .base import AGENT_TRACE_CMD, CodingAgentAdapter
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
CODEX_DIR = Path.home() / ".codex"
|
|
44
|
+
CODEX_CONFIG_FILE = CODEX_DIR / "config.toml"
|
|
45
|
+
|
|
46
|
+
# Shell snippet Codex's ``notify`` will invoke. It takes Codex's JSON
|
|
47
|
+
# payload (passed as the last argv), tags it with a ``hook_event_name``
|
|
48
|
+
# keyed off the payload's ``type`` field, and pipes the result into
|
|
49
|
+
# ``agent-trace record``.
|
|
50
|
+
_NOTIFY_SHELL = (
|
|
51
|
+
"payload=\"$1\"; "
|
|
52
|
+
"case \"$payload\" in *agent-turn-complete*) ev=CodexTurnComplete;; "
|
|
53
|
+
"*) ev=CodexUnknown;; esac; "
|
|
54
|
+
'printf \'%s\' "$payload" | python3 -c '
|
|
55
|
+
"'import json,sys; "
|
|
56
|
+
'd=json.loads(sys.stdin.read() or "{}"); '
|
|
57
|
+
'd["hook_event_name"]=sys.argv[1]; '
|
|
58
|
+
"print(json.dumps(d))' "
|
|
59
|
+
"\"$ev\" | agent-trace record"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Marker line written into config.toml when our notify entry is present.
|
|
63
|
+
_AGENT_TRACE_NOTIFY_MARKER = "# agent-trace:notify"
|
|
64
|
+
_NOTIFY_ARRAY_PREFIX = "notify = "
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------
|
|
68
|
+
# Event translators (hook payload → trace record)
|
|
69
|
+
# ---------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def _TurnComplete(d):
|
|
72
|
+
from ..record import transcript_path_from_hook
|
|
73
|
+
from ..trace import create_trace, resolve_file_project
|
|
74
|
+
|
|
75
|
+
anchor = d.get("cwd") or os.getcwd()
|
|
76
|
+
res = resolve_file_project(".sessions", anchor_path=anchor)
|
|
77
|
+
last_msg = d.get("last-assistant-message") or d.get("last_assistant_message")
|
|
78
|
+
metadata = {
|
|
79
|
+
"event": "turn_complete",
|
|
80
|
+
"turn_id": d.get("turn-id") or d.get("turn_id"),
|
|
81
|
+
"session_id": d.get("session_id") or d.get("session-id"),
|
|
82
|
+
"input_messages": d.get("input-messages") or d.get("input_messages"),
|
|
83
|
+
"last_assistant_message": last_msg,
|
|
84
|
+
}
|
|
85
|
+
return create_trace(
|
|
86
|
+
"ai", ".sessions",
|
|
87
|
+
model=d.get("model") or d.get("model_id"),
|
|
88
|
+
transcript=transcript_path_from_hook(d),
|
|
89
|
+
metadata=metadata,
|
|
90
|
+
anchor_path=anchor,
|
|
91
|
+
resolution=res,
|
|
92
|
+
), "CodexTurnComplete"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _SessionStart(d):
|
|
96
|
+
from ..trace import create_trace, resolve_file_project
|
|
97
|
+
|
|
98
|
+
anchor = d.get("cwd") or os.getcwd()
|
|
99
|
+
res = resolve_file_project(".sessions", anchor_path=anchor)
|
|
100
|
+
return create_trace(
|
|
101
|
+
"ai", ".sessions",
|
|
102
|
+
model=d.get("model") or d.get("model_id"),
|
|
103
|
+
metadata={
|
|
104
|
+
"event": "session_start",
|
|
105
|
+
"session_id": d.get("session_id") or d.get("session-id"),
|
|
106
|
+
"source": d.get("source"),
|
|
107
|
+
},
|
|
108
|
+
anchor_path=anchor,
|
|
109
|
+
resolution=res,
|
|
110
|
+
), "CodexSessionStart"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------
|
|
114
|
+
# Adapter
|
|
115
|
+
# ---------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
class CodexAdapter(CodingAgentAdapter):
|
|
118
|
+
name = "codex"
|
|
119
|
+
display_name = "Codex CLI"
|
|
120
|
+
rules_dir = ".codex/rules"
|
|
121
|
+
rule_extension = ".md"
|
|
122
|
+
|
|
123
|
+
EVENTS = {
|
|
124
|
+
"CodexTurnComplete": _TurnComplete,
|
|
125
|
+
"CodexSessionStart": _SessionStart,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def global_config_path(self) -> Path:
|
|
129
|
+
return CODEX_CONFIG_FILE
|
|
130
|
+
|
|
131
|
+
def project_config_paths(self) -> tuple[str, ...]:
|
|
132
|
+
# Codex has no project-scoped config, but we drop a marker so
|
|
133
|
+
# ``doctor`` can show "project notify hook installed via Codex".
|
|
134
|
+
return (".codex/agent-trace.installed",)
|
|
135
|
+
|
|
136
|
+
def detect_tool_info(self) -> dict | None:
|
|
137
|
+
ver = os.environ.get("CODEX_VERSION")
|
|
138
|
+
if ver:
|
|
139
|
+
return {"name": "codex", "version": ver}
|
|
140
|
+
if os.environ.get("CODEX_HOME"):
|
|
141
|
+
return {"name": "codex"}
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def inject(
|
|
145
|
+
self,
|
|
146
|
+
record_invocation: str, # noqa: ARG002 — recorded inside _NOTIFY_SHELL
|
|
147
|
+
project_dir: str | None = None,
|
|
148
|
+
*,
|
|
149
|
+
global_install: bool = False,
|
|
150
|
+
) -> bool:
|
|
151
|
+
if not global_install:
|
|
152
|
+
if project_dir is None:
|
|
153
|
+
project_dir = os.getcwd()
|
|
154
|
+
project_marker = Path(project_dir) / ".codex" / "agent-trace.installed"
|
|
155
|
+
project_marker.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
project_marker.write_text(
|
|
157
|
+
"Codex's notify hook is user-scoped (~/.codex/config.toml).\n"
|
|
158
|
+
"Run: agent-trace hooks setup-global --tool codex\n",
|
|
159
|
+
)
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
CODEX_DIR.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
existing = ""
|
|
164
|
+
if CODEX_CONFIG_FILE.exists():
|
|
165
|
+
try:
|
|
166
|
+
existing = CODEX_CONFIG_FILE.read_text()
|
|
167
|
+
except OSError:
|
|
168
|
+
existing = ""
|
|
169
|
+
|
|
170
|
+
if _AGENT_TRACE_NOTIFY_MARKER in existing and AGENT_TRACE_CMD in existing:
|
|
171
|
+
return True # idempotent
|
|
172
|
+
|
|
173
|
+
cleaned_lines: list[str] = []
|
|
174
|
+
skip_next_notify = False
|
|
175
|
+
for line in existing.splitlines():
|
|
176
|
+
if line.strip() == _AGENT_TRACE_NOTIFY_MARKER:
|
|
177
|
+
skip_next_notify = True
|
|
178
|
+
continue
|
|
179
|
+
if skip_next_notify and line.lstrip().startswith(_NOTIFY_ARRAY_PREFIX):
|
|
180
|
+
skip_next_notify = False
|
|
181
|
+
continue
|
|
182
|
+
skip_next_notify = False
|
|
183
|
+
cleaned_lines.append(line)
|
|
184
|
+
|
|
185
|
+
block = (
|
|
186
|
+
f"{_AGENT_TRACE_NOTIFY_MARKER}\n"
|
|
187
|
+
f"notify = [\"bash\", \"-lc\", {_quote_toml(_NOTIFY_SHELL)}, \"--\"]\n"
|
|
188
|
+
)
|
|
189
|
+
if cleaned_lines and cleaned_lines[-1].strip():
|
|
190
|
+
cleaned_lines.append("")
|
|
191
|
+
cleaned_lines.append(block.rstrip())
|
|
192
|
+
CODEX_CONFIG_FILE.write_text("\n".join(cleaned_lines) + "\n")
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def remove(self) -> bool:
|
|
196
|
+
if not CODEX_CONFIG_FILE.is_file():
|
|
197
|
+
return False
|
|
198
|
+
try:
|
|
199
|
+
text = CODEX_CONFIG_FILE.read_text()
|
|
200
|
+
except OSError:
|
|
201
|
+
return False
|
|
202
|
+
if _AGENT_TRACE_NOTIFY_MARKER not in text:
|
|
203
|
+
return False
|
|
204
|
+
new_lines: list[str] = []
|
|
205
|
+
skip_next_notify = False
|
|
206
|
+
removed = False
|
|
207
|
+
for line in text.splitlines():
|
|
208
|
+
if line.strip() == _AGENT_TRACE_NOTIFY_MARKER:
|
|
209
|
+
skip_next_notify = True
|
|
210
|
+
removed = True
|
|
211
|
+
continue
|
|
212
|
+
if skip_next_notify and line.lstrip().startswith(_NOTIFY_ARRAY_PREFIX):
|
|
213
|
+
skip_next_notify = False
|
|
214
|
+
continue
|
|
215
|
+
skip_next_notify = False
|
|
216
|
+
new_lines.append(line)
|
|
217
|
+
if removed:
|
|
218
|
+
CODEX_CONFIG_FILE.write_text(("\n".join(new_lines).rstrip() + "\n") if new_lines else "")
|
|
219
|
+
return removed
|
|
220
|
+
|
|
221
|
+
def is_installed(self, *, global_only: bool = True) -> bool:
|
|
222
|
+
try:
|
|
223
|
+
raw = CODEX_CONFIG_FILE.read_text()
|
|
224
|
+
except (OSError, FileNotFoundError):
|
|
225
|
+
return False
|
|
226
|
+
return _AGENT_TRACE_NOTIFY_MARKER in raw and AGENT_TRACE_CMD in raw
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _quote_toml(value: str) -> str:
|
|
230
|
+
"""Quote a string for inclusion in a TOML array element."""
|
|
231
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
232
|
+
return f'"{escaped}"'
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cursor adapter — hooks injection, event translators, summary lifecycle.
|
|
3
|
+
|
|
4
|
+
Everything Cursor-specific lives here:
|
|
5
|
+
- hook config writer / remover
|
|
6
|
+
- hook events Cursor emits (``afterFileEdit`` etc.) and their translators
|
|
7
|
+
- env vars Cursor sets (``CURSOR_TRANSCRIPT_PATH``, ``CURSOR_PROJECT_DIR``)
|
|
8
|
+
- which events trigger the summary command (``afterAgentResponse``,
|
|
9
|
+
``sessionEnd``)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .base import AGENT_TRACE_CMD, CodingAgentAdapter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CURSOR_HOOKS_FILE = ".cursor/hooks.json"
|
|
22
|
+
CURSOR_GLOBAL_HOOKS_FILE = Path.home() / ".cursor" / "hooks.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Hook events Cursor emits that we listen to (used by the inject step).
|
|
26
|
+
_CURSOR_HOOK_EVENTS = (
|
|
27
|
+
"sessionStart",
|
|
28
|
+
"sessionEnd",
|
|
29
|
+
"afterFileEdit",
|
|
30
|
+
"afterTabFileEdit",
|
|
31
|
+
"afterShellExecution",
|
|
32
|
+
"afterAgentResponse",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------
|
|
37
|
+
# Event translators (hook payload → trace record)
|
|
38
|
+
# ---------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def _afterFileEdit(d):
|
|
41
|
+
from ..record import (
|
|
42
|
+
_get_next_sequence,
|
|
43
|
+
_try_read_file,
|
|
44
|
+
transcript_path_from_hook,
|
|
45
|
+
)
|
|
46
|
+
from ..trace import (
|
|
47
|
+
compute_range_positions,
|
|
48
|
+
create_trace,
|
|
49
|
+
resolve_file_project,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
edits = d.get("edits", [])
|
|
53
|
+
fp = d.get("file_path", "")
|
|
54
|
+
fc = _try_read_file(fp) if fp else None
|
|
55
|
+
session_id = d.get("conversation_id") or ""
|
|
56
|
+
res = resolve_file_project(fp)
|
|
57
|
+
seq = _get_next_sequence(session_id, res.repo_root if res else None) if session_id else None
|
|
58
|
+
return create_trace(
|
|
59
|
+
"ai", fp,
|
|
60
|
+
model=d.get("model"),
|
|
61
|
+
range_positions=compute_range_positions(edits, fc),
|
|
62
|
+
range_contents=[e["new_string"] for e in edits if e.get("new_string")],
|
|
63
|
+
transcript=transcript_path_from_hook(d),
|
|
64
|
+
metadata={"conversation_id": d.get("conversation_id"), "generation_id": d.get("generation_id")},
|
|
65
|
+
edit_sequence=seq,
|
|
66
|
+
resolution=res,
|
|
67
|
+
), "afterFileEdit"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _afterTabFileEdit(d):
|
|
71
|
+
from ..record import _get_next_sequence
|
|
72
|
+
from ..trace import (
|
|
73
|
+
compute_range_positions,
|
|
74
|
+
create_trace,
|
|
75
|
+
resolve_file_project,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
edits = d.get("edits", [])
|
|
79
|
+
fp = d.get("file_path", "")
|
|
80
|
+
session_id = d.get("conversation_id") or ""
|
|
81
|
+
res = resolve_file_project(fp)
|
|
82
|
+
seq = _get_next_sequence(session_id, res.repo_root if res else None) if session_id else None
|
|
83
|
+
return create_trace(
|
|
84
|
+
"ai", fp,
|
|
85
|
+
model=d.get("model"),
|
|
86
|
+
range_positions=compute_range_positions(edits),
|
|
87
|
+
range_contents=[e["new_string"] for e in edits if e.get("new_string")],
|
|
88
|
+
metadata={"conversation_id": d.get("conversation_id"), "generation_id": d.get("generation_id")},
|
|
89
|
+
edit_sequence=seq,
|
|
90
|
+
resolution=res,
|
|
91
|
+
), "afterTabFileEdit"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _afterShellExecution(d):
|
|
95
|
+
from ..record import project_dir_from_hook, transcript_path_from_hook
|
|
96
|
+
from ..trace import create_trace, resolve_file_project
|
|
97
|
+
|
|
98
|
+
anchor = project_dir_from_hook(d)
|
|
99
|
+
res = resolve_file_project(".shell-history", anchor_path=anchor)
|
|
100
|
+
return create_trace(
|
|
101
|
+
"ai", ".shell-history",
|
|
102
|
+
model=d.get("model"),
|
|
103
|
+
transcript=transcript_path_from_hook(d),
|
|
104
|
+
metadata={
|
|
105
|
+
"conversation_id": d.get("conversation_id"),
|
|
106
|
+
"generation_id": d.get("generation_id"),
|
|
107
|
+
"command": d.get("command"),
|
|
108
|
+
"duration_ms": d.get("duration"),
|
|
109
|
+
},
|
|
110
|
+
anchor_path=anchor,
|
|
111
|
+
resolution=res,
|
|
112
|
+
), "afterShellExecution"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _sessionStart(d):
|
|
116
|
+
from ..record import project_dir_from_hook
|
|
117
|
+
from ..trace import create_trace, resolve_file_project
|
|
118
|
+
|
|
119
|
+
anchor = project_dir_from_hook(d)
|
|
120
|
+
res = resolve_file_project(".sessions", anchor_path=anchor)
|
|
121
|
+
return create_trace(
|
|
122
|
+
"ai", ".sessions",
|
|
123
|
+
model=d.get("model"),
|
|
124
|
+
metadata={
|
|
125
|
+
"event": "session_start",
|
|
126
|
+
"session_id": d.get("session_id"),
|
|
127
|
+
"conversation_id": d.get("conversation_id"),
|
|
128
|
+
"is_background_agent": d.get("is_background_agent"),
|
|
129
|
+
"composer_mode": d.get("composer_mode"),
|
|
130
|
+
},
|
|
131
|
+
anchor_path=anchor,
|
|
132
|
+
resolution=res,
|
|
133
|
+
), "sessionStart"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _sessionEnd(d):
|
|
137
|
+
from ..record import project_dir_from_hook, transcript_path_from_hook
|
|
138
|
+
from ..trace import create_trace, resolve_file_project
|
|
139
|
+
|
|
140
|
+
anchor = project_dir_from_hook(d)
|
|
141
|
+
res = resolve_file_project(".sessions", anchor_path=anchor)
|
|
142
|
+
return create_trace(
|
|
143
|
+
"ai", ".sessions",
|
|
144
|
+
model=d.get("model"),
|
|
145
|
+
transcript=transcript_path_from_hook(d),
|
|
146
|
+
metadata={
|
|
147
|
+
"event": "session_end",
|
|
148
|
+
"session_id": d.get("session_id"),
|
|
149
|
+
"conversation_id": d.get("conversation_id"),
|
|
150
|
+
"reason": d.get("reason"),
|
|
151
|
+
"duration_ms": d.get("duration_ms"),
|
|
152
|
+
},
|
|
153
|
+
anchor_path=anchor,
|
|
154
|
+
resolution=res,
|
|
155
|
+
), "sessionEnd"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------
|
|
159
|
+
# Adapter
|
|
160
|
+
# ---------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
class CursorAdapter(CodingAgentAdapter):
|
|
163
|
+
name = "cursor"
|
|
164
|
+
display_name = "Cursor"
|
|
165
|
+
rules_dir = ".cursor/rules"
|
|
166
|
+
rule_extension = ".mdc"
|
|
167
|
+
|
|
168
|
+
EVENTS = {
|
|
169
|
+
"afterFileEdit": _afterFileEdit,
|
|
170
|
+
"afterTabFileEdit": _afterTabFileEdit,
|
|
171
|
+
"afterShellExecution": _afterShellExecution,
|
|
172
|
+
"sessionStart": _sessionStart,
|
|
173
|
+
"sessionEnd": _sessionEnd,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# ``afterAgentResponse`` is "agent finished a turn" in Cursor; no
|
|
177
|
+
# trace record, but it should fire the summary command.
|
|
178
|
+
summary_only_events = ("afterAgentResponse",)
|
|
179
|
+
# ``sessionEnd`` *both* ends the session (-> summary) and gets
|
|
180
|
+
# routed to the trace handler so we record a session-end trace.
|
|
181
|
+
summary_then_trace_events = ("sessionEnd",)
|
|
182
|
+
|
|
183
|
+
transcript_env_vars = ("CURSOR_TRANSCRIPT_PATH",)
|
|
184
|
+
project_dir_env_vars = ("CURSOR_PROJECT_DIR",)
|
|
185
|
+
|
|
186
|
+
def global_config_path(self) -> Path:
|
|
187
|
+
return CURSOR_GLOBAL_HOOKS_FILE
|
|
188
|
+
|
|
189
|
+
def project_config_paths(self) -> tuple[str, ...]:
|
|
190
|
+
return (CURSOR_HOOKS_FILE,)
|
|
191
|
+
|
|
192
|
+
def detect_tool_info(self) -> dict | None:
|
|
193
|
+
ver = os.environ.get("CURSOR_VERSION")
|
|
194
|
+
if ver:
|
|
195
|
+
return {"name": "cursor", "version": ver}
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def inject(
|
|
199
|
+
self,
|
|
200
|
+
record_invocation: str,
|
|
201
|
+
project_dir: str | None = None,
|
|
202
|
+
*,
|
|
203
|
+
global_install: bool = False,
|
|
204
|
+
) -> bool:
|
|
205
|
+
if global_install:
|
|
206
|
+
hooks_path = CURSOR_GLOBAL_HOOKS_FILE
|
|
207
|
+
else:
|
|
208
|
+
if project_dir is None:
|
|
209
|
+
project_dir = os.getcwd()
|
|
210
|
+
hooks_path = Path(project_dir) / CURSOR_HOOKS_FILE
|
|
211
|
+
|
|
212
|
+
hooks_path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
|
|
214
|
+
if hooks_path.exists():
|
|
215
|
+
try:
|
|
216
|
+
config = json.loads(hooks_path.read_text())
|
|
217
|
+
except (json.JSONDecodeError, OSError):
|
|
218
|
+
config = {}
|
|
219
|
+
else:
|
|
220
|
+
config = {}
|
|
221
|
+
|
|
222
|
+
config.setdefault("version", 1)
|
|
223
|
+
config.setdefault("hooks", {})
|
|
224
|
+
|
|
225
|
+
for event in _CURSOR_HOOK_EVENTS:
|
|
226
|
+
existing = config["hooks"].get(event, [])
|
|
227
|
+
already = any(
|
|
228
|
+
record_invocation in (h.get("command", "") if isinstance(h, dict) else "")
|
|
229
|
+
for h in existing
|
|
230
|
+
)
|
|
231
|
+
if not already:
|
|
232
|
+
existing.append({"command": record_invocation})
|
|
233
|
+
config["hooks"][event] = existing
|
|
234
|
+
|
|
235
|
+
hooks_path.write_text(json.dumps(config, indent=2) + "\n")
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
def remove(self) -> bool:
|
|
239
|
+
return _remove_agent_trace_from_cursor(CURSOR_GLOBAL_HOOKS_FILE)
|
|
240
|
+
|
|
241
|
+
def is_installed(self, *, global_only: bool = True) -> bool:
|
|
242
|
+
try:
|
|
243
|
+
raw = CURSOR_GLOBAL_HOOKS_FILE.read_text()
|
|
244
|
+
except (OSError, FileNotFoundError):
|
|
245
|
+
return False
|
|
246
|
+
return AGENT_TRACE_CMD in raw
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _remove_agent_trace_from_cursor(hooks_path: Path) -> bool:
|
|
250
|
+
if not hooks_path.is_file():
|
|
251
|
+
return False
|
|
252
|
+
try:
|
|
253
|
+
config = json.loads(hooks_path.read_text())
|
|
254
|
+
except (json.JSONDecodeError, OSError):
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
hooks = config.get("hooks")
|
|
258
|
+
if not isinstance(hooks, dict):
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
changed = False
|
|
262
|
+
for event, entries in list(hooks.items()):
|
|
263
|
+
if not isinstance(entries, list):
|
|
264
|
+
continue
|
|
265
|
+
filtered = [
|
|
266
|
+
h for h in entries
|
|
267
|
+
if not (isinstance(h, dict) and AGENT_TRACE_CMD in h.get("command", ""))
|
|
268
|
+
]
|
|
269
|
+
if len(filtered) != len(entries):
|
|
270
|
+
changed = True
|
|
271
|
+
if filtered:
|
|
272
|
+
hooks[event] = filtered
|
|
273
|
+
else:
|
|
274
|
+
del hooks[event]
|
|
275
|
+
|
|
276
|
+
if changed:
|
|
277
|
+
hooks_path.write_text(json.dumps(config, indent=2) + "\n")
|
|
278
|
+
return changed
|
agent_trace/hooks/git.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
GIT_NOTES_REFSPEC = "+refs/notes/agent-trace:refs/notes/agent-trace"
|
|
9
|
+
|
|
10
|
+
AGENT_TRACE_COMMIT_LINK_CMD = "agent-trace commit-link"
|
|
11
|
+
|
|
12
|
+
GIT_HOOK_MARKER = AGENT_TRACE_COMMIT_LINK_CMD
|
|
13
|
+
GIT_HOOK_SCRIPT = """\
|
|
14
|
+
# agent-trace: link commit to AI traces
|
|
15
|
+
agent-trace commit-link 2>/dev/null || true
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
GIT_POST_REWRITE_MARKER = "agent-trace rewrite-ledger"
|
|
19
|
+
GIT_POST_REWRITE_SCRIPT = """\
|
|
20
|
+
# agent-trace: remap ledgers after rebase/amend
|
|
21
|
+
agent-trace rewrite-ledger 2>/dev/null || true
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configure_git_hooks(project_dir: str | None = None) -> bool:
|
|
26
|
+
if project_dir is None:
|
|
27
|
+
project_dir = os.getcwd()
|
|
28
|
+
|
|
29
|
+
git_dir = Path(project_dir) / ".git"
|
|
30
|
+
if not git_dir.is_dir():
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
hooks_dir = git_dir / "hooks"
|
|
34
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
hook_path = hooks_dir / "post-commit"
|
|
36
|
+
|
|
37
|
+
if hook_path.exists():
|
|
38
|
+
try:
|
|
39
|
+
content = hook_path.read_text()
|
|
40
|
+
except OSError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
if GIT_HOOK_MARKER in content:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
if not content.endswith("\n"):
|
|
47
|
+
content += "\n"
|
|
48
|
+
content += "\n" + GIT_HOOK_SCRIPT
|
|
49
|
+
hook_path.write_text(content)
|
|
50
|
+
else:
|
|
51
|
+
hook_path.write_text("#!/bin/sh\n" + GIT_HOOK_SCRIPT)
|
|
52
|
+
|
|
53
|
+
current = hook_path.stat().st_mode
|
|
54
|
+
hook_path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
55
|
+
|
|
56
|
+
configure_git_post_rewrite_hook(project_dir)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def configure_git_post_rewrite_hook(project_dir: str | None = None) -> bool:
|
|
61
|
+
if project_dir is None:
|
|
62
|
+
project_dir = os.getcwd()
|
|
63
|
+
|
|
64
|
+
git_dir = Path(project_dir) / ".git"
|
|
65
|
+
if not git_dir.is_dir():
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
hooks_dir = git_dir / "hooks"
|
|
69
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
hook_path = hooks_dir / "post-rewrite"
|
|
71
|
+
|
|
72
|
+
if hook_path.exists():
|
|
73
|
+
try:
|
|
74
|
+
content = hook_path.read_text()
|
|
75
|
+
except OSError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
if GIT_POST_REWRITE_MARKER in content:
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
if not content.endswith("\n"):
|
|
82
|
+
content += "\n"
|
|
83
|
+
content += "\n" + GIT_POST_REWRITE_SCRIPT
|
|
84
|
+
hook_path.write_text(content)
|
|
85
|
+
else:
|
|
86
|
+
hook_path.write_text("#!/bin/sh\n" + GIT_POST_REWRITE_SCRIPT)
|
|
87
|
+
|
|
88
|
+
current = hook_path.stat().st_mode
|
|
89
|
+
hook_path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def configure_git_notes_refspecs(project_dir: str | None = None, remote_name: str = "origin") -> bool:
|
|
94
|
+
if project_dir is None:
|
|
95
|
+
project_dir = os.getcwd()
|
|
96
|
+
|
|
97
|
+
git_dir = Path(project_dir) / ".git"
|
|
98
|
+
if not git_dir.is_dir():
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
r = subprocess.run(
|
|
103
|
+
["git", "-C", project_dir, "remote", "get-url", remote_name],
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
timeout=10,
|
|
107
|
+
)
|
|
108
|
+
if r.returncode != 0:
|
|
109
|
+
return False
|
|
110
|
+
except Exception:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
for key in ("fetch", "push"):
|
|
114
|
+
try:
|
|
115
|
+
cur = subprocess.run(
|
|
116
|
+
["git", "-C", project_dir, "config", "--get-all", f"remote.{remote_name}.{key}"],
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
timeout=10,
|
|
120
|
+
)
|
|
121
|
+
existing = cur.stdout if cur.returncode == 0 else ""
|
|
122
|
+
except Exception:
|
|
123
|
+
existing = ""
|
|
124
|
+
if "refs/notes/agent-trace" in existing:
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
subprocess.run(
|
|
128
|
+
[
|
|
129
|
+
"git",
|
|
130
|
+
"-C",
|
|
131
|
+
project_dir,
|
|
132
|
+
"config",
|
|
133
|
+
"--add",
|
|
134
|
+
f"remote.{remote_name}.{key}",
|
|
135
|
+
GIT_NOTES_REFSPEC,
|
|
136
|
+
],
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=10,
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
except Exception:
|
|
143
|
+
return False
|
|
144
|
+
return True
|