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
@@ -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
@@ -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