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
agent_trace/cli.py
ADDED
|
@@ -0,0 +1,2680 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent-trace CLI — terminal commands for AI code tracing.
|
|
3
|
+
|
|
4
|
+
Zero external dependencies — uses only the Python standard library.
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
agent-trace init Initialize tracing for the current project
|
|
8
|
+
agent-trace status Show tracing status
|
|
9
|
+
agent-trace reset Reconfigure tracing settings
|
|
10
|
+
agent-trace config show Show persisted configuration
|
|
11
|
+
agent-trace config set Set one configuration field
|
|
12
|
+
agent-trace config reset Reset one configuration field/group
|
|
13
|
+
agent-trace hooks setup-global Install global hooks for all tools
|
|
14
|
+
agent-trace hooks remove-global Remove global hooks
|
|
15
|
+
agent-trace hooks status Show global hook status
|
|
16
|
+
agent-trace record Record a trace from stdin (used by hooks)
|
|
17
|
+
agent-trace commit-link Link current commit to traces (called by git hook)
|
|
18
|
+
agent-trace blame <file> Show AI attribution for a file
|
|
19
|
+
agent-trace context <file> Get conversation context for AI-attributed code
|
|
20
|
+
agent-trace rule add <name> Add a prebuilt rule for a coding agent
|
|
21
|
+
agent-trace rule remove <name> Remove a rule
|
|
22
|
+
agent-trace rule show Show which rules are configured
|
|
23
|
+
agent-trace rule list List available prebuilt rules
|
|
24
|
+
agent-trace viewer Open the file viewer (browse files, git + agent-trace blame)
|
|
25
|
+
agent-trace remote add/list/show/set-url/set-token/remove/rename/default
|
|
26
|
+
agent-trace push Push local traces to remote
|
|
27
|
+
agent-trace pull Pull remote traces to local
|
|
28
|
+
agent-trace sync Push + pull in one go
|
|
29
|
+
agent-trace set globaluser Set a global auth token
|
|
30
|
+
agent-trace remove globaluser Remove the global auth token
|
|
31
|
+
agent-trace projects List registered project IDs (or: projects show <id>)
|
|
32
|
+
agent-trace adopt [path] Register a git repo and print its stable project_id
|
|
33
|
+
agent-trace notes ... Git notes (attach, rebuild, show, push, pull, …)
|
|
34
|
+
agent-trace summary ... Pluggable session summaries (enable, generate, show)
|
|
35
|
+
agent-trace doctor Check hooks, config, remotes, and tooling
|
|
36
|
+
|
|
37
|
+
Global flags:
|
|
38
|
+
--telemetry on|off|status Opt-in anonymous usage telemetry (default off)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import dataclasses
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
import shlex
|
|
49
|
+
import shutil
|
|
50
|
+
import stat
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
import time
|
|
54
|
+
import urllib.error
|
|
55
|
+
import urllib.request
|
|
56
|
+
|
|
57
|
+
from .config import (
|
|
58
|
+
get_global_config,
|
|
59
|
+
get_global_config_file,
|
|
60
|
+
get_project_config,
|
|
61
|
+
save_global_config,
|
|
62
|
+
save_project_config,
|
|
63
|
+
)
|
|
64
|
+
from .blame import blame_file
|
|
65
|
+
from .commit_link import create_commit_link
|
|
66
|
+
from .context import context_command
|
|
67
|
+
from .registry import list_projects, lookup_or_create_project_id
|
|
68
|
+
from .trace import (
|
|
69
|
+
cli_resolve_project_root,
|
|
70
|
+
discover_ambiguous_repo_roots,
|
|
71
|
+
get_vcs_info,
|
|
72
|
+
git_repo_root_for_path,
|
|
73
|
+
)
|
|
74
|
+
from .hooks import (
|
|
75
|
+
AGENT_TRACE_CMD,
|
|
76
|
+
adapter_names,
|
|
77
|
+
configure_project_hooks,
|
|
78
|
+
iter_adapters,
|
|
79
|
+
remove_global_hooks,
|
|
80
|
+
setup_global_hooks,
|
|
81
|
+
)
|
|
82
|
+
from .hooks.git import (
|
|
83
|
+
configure_git_hooks,
|
|
84
|
+
configure_git_notes_refspecs,
|
|
85
|
+
configure_git_post_rewrite_hook,
|
|
86
|
+
)
|
|
87
|
+
from .rules import add_rule, remove_rule, show_rules, list_available_rules, TOOL_CHOICES
|
|
88
|
+
from .record import record_from_stdin
|
|
89
|
+
from .rewrite import rewrite_ledgers
|
|
90
|
+
from .remote import (
|
|
91
|
+
ProjectRegistrationError,
|
|
92
|
+
RemoteUrlError,
|
|
93
|
+
TokenScopeError,
|
|
94
|
+
WhoamiUnsupportedError,
|
|
95
|
+
add_remote as remote_add,
|
|
96
|
+
assert_token_matches_url,
|
|
97
|
+
get_default_remote,
|
|
98
|
+
list_remotes as remote_list,
|
|
99
|
+
parse_remote_url,
|
|
100
|
+
register_project_via_remote,
|
|
101
|
+
remove_remote as remote_remove,
|
|
102
|
+
rename_remote as remote_rename,
|
|
103
|
+
set_default_remote,
|
|
104
|
+
set_remote_token,
|
|
105
|
+
set_remote_url,
|
|
106
|
+
show_remote as remote_show,
|
|
107
|
+
whoami as remote_whoami,
|
|
108
|
+
)
|
|
109
|
+
from .sync import pull as sync_pull, push as sync_push, status as sync_status
|
|
110
|
+
from .telemetry import (
|
|
111
|
+
maybe_report_cli_run,
|
|
112
|
+
set_telemetry_enabled,
|
|
113
|
+
telemetry_status_lines,
|
|
114
|
+
)
|
|
115
|
+
from .summary_presets import (
|
|
116
|
+
DEFAULT_OLLAMA_MODEL,
|
|
117
|
+
PRESET_ALIASES,
|
|
118
|
+
build_preset_command,
|
|
119
|
+
list_summary_presets,
|
|
120
|
+
run_summary_preset,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
VERSION = "0.1.0"
|
|
124
|
+
|
|
125
|
+
VIEWER_BIN = os.path.expanduser("~/.agent-trace/bin/agent-trace-viewer")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _project_hook_paths_for(adapter) -> list[str]:
|
|
129
|
+
"""Return project-scoped config paths declared by the adapter.
|
|
130
|
+
|
|
131
|
+
Adapters expose ``project_config_paths()`` to list every relative
|
|
132
|
+
path that signals "agent-trace hooks installed at project scope".
|
|
133
|
+
The CLI just iterates — no per-tool branches.
|
|
134
|
+
"""
|
|
135
|
+
paths_fn = getattr(adapter, "project_config_paths", None)
|
|
136
|
+
if callable(paths_fn):
|
|
137
|
+
try:
|
|
138
|
+
return list(paths_fn())
|
|
139
|
+
except Exception:
|
|
140
|
+
return []
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# -------------------------------------------------------------------
|
|
145
|
+
# Interactive helpers (replaces click.prompt / click.confirm)
|
|
146
|
+
# -------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _prompt(message, default=None, choices=None):
|
|
149
|
+
"""Interactive text prompt."""
|
|
150
|
+
hint = ""
|
|
151
|
+
if choices:
|
|
152
|
+
hint += f" ({'/'.join(choices)})"
|
|
153
|
+
if default is not None:
|
|
154
|
+
hint += f" [{default}]"
|
|
155
|
+
|
|
156
|
+
while True:
|
|
157
|
+
try:
|
|
158
|
+
value = input(f"{message}{hint}: ").strip()
|
|
159
|
+
except (EOFError, KeyboardInterrupt):
|
|
160
|
+
print()
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
if not value:
|
|
164
|
+
if default is not None:
|
|
165
|
+
return default
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
if choices and value.lower() not in [c.lower() for c in choices]:
|
|
169
|
+
print(f" Please choose from: {', '.join(choices)}")
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _confirm(message, default=True):
|
|
176
|
+
"""Interactive yes / no prompt."""
|
|
177
|
+
hint = " [Y/n]" if default else " [y/N]"
|
|
178
|
+
try:
|
|
179
|
+
value = input(f"{message}{hint}: ").strip().lower()
|
|
180
|
+
except (EOFError, KeyboardInterrupt):
|
|
181
|
+
print()
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
if not value:
|
|
185
|
+
return default
|
|
186
|
+
return value in ("y", "yes")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ===================================================================
|
|
190
|
+
# init
|
|
191
|
+
# ===================================================================
|
|
192
|
+
|
|
193
|
+
def cmd_init(_args):
|
|
194
|
+
"""Zero-prompt init — applies sensible local defaults and wires up hooks.
|
|
195
|
+
|
|
196
|
+
Like ``git init``: creates local-only state. Remote sharing is opt-in and
|
|
197
|
+
set up later via ``agent-trace remote add`` (and ``agent-trace notes push``
|
|
198
|
+
/ ``agent-trace push``).
|
|
199
|
+
"""
|
|
200
|
+
config = get_project_config()
|
|
201
|
+
if config is not None:
|
|
202
|
+
print("agent-trace is already initialized for this project.")
|
|
203
|
+
print("Use 'agent-trace reset' to change configuration.")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
cwd = os.getcwd()
|
|
207
|
+
if not git_repo_root_for_path(cwd):
|
|
208
|
+
print("agent-trace init: not a git repository.", file=sys.stderr)
|
|
209
|
+
print("Create or enter a git repo, then run init again.", file=sys.stderr)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
|
|
212
|
+
if get_vcs_info(cwd) is None:
|
|
213
|
+
print("agent-trace init: repository has no commits yet.", file=sys.stderr)
|
|
214
|
+
print("Create at least one commit, then run 'agent-trace init' again.", file=sys.stderr)
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
project_config: dict = {
|
|
218
|
+
"notes": {
|
|
219
|
+
"enabled": True,
|
|
220
|
+
"include_ledger": False,
|
|
221
|
+
"include_summary": True,
|
|
222
|
+
"include_prompts": True,
|
|
223
|
+
"all_session_conversations": False,
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
save_project_config(project_config)
|
|
227
|
+
|
|
228
|
+
from .storage import get_project_config_path, resolve_project_id
|
|
229
|
+
|
|
230
|
+
pid = resolve_project_id(cwd, create=True)
|
|
231
|
+
|
|
232
|
+
print("Initializing agent-trace...\n")
|
|
233
|
+
print(f" Project id: {pid}")
|
|
234
|
+
print(f" Data dir: {get_project_config_path(pid).parent if pid else '?'}")
|
|
235
|
+
|
|
236
|
+
# Git notes — defaults on; per-line ledger off unless configured
|
|
237
|
+
print(" Git notes: enabled (summary + prompts; per-line ledger off by default)")
|
|
238
|
+
|
|
239
|
+
notes_remote = "origin"
|
|
240
|
+
notes_refspec_ok = False
|
|
241
|
+
if os.path.isdir(".git"):
|
|
242
|
+
notes_refspec_ok = configure_git_notes_refspecs(remote_name=notes_remote)
|
|
243
|
+
if notes_refspec_ok:
|
|
244
|
+
print(f" Note refspec: configured on remote \"{notes_remote}\"")
|
|
245
|
+
else:
|
|
246
|
+
print(f" Note refspec: skipped (no git remote \"{notes_remote}\" yet — "
|
|
247
|
+
"rerun 'agent-trace reset' after adding one)")
|
|
248
|
+
|
|
249
|
+
# Hooks — install everything by default, skip if global already handles it.
|
|
250
|
+
# Driven by the adapter registry: new agents are picked up automatically.
|
|
251
|
+
for adapter in iter_adapters():
|
|
252
|
+
label = adapter.display_name or adapter.name
|
|
253
|
+
global_path = adapter.global_config_path()
|
|
254
|
+
if adapter.is_installed():
|
|
255
|
+
print(f" {label} hooks: global ({global_path}) — already set")
|
|
256
|
+
else:
|
|
257
|
+
adapter.inject(AGENT_TRACE_CMD, global_install=False)
|
|
258
|
+
print(f" {label} hooks: configured (project-level)")
|
|
259
|
+
|
|
260
|
+
if os.path.isdir(".git"):
|
|
261
|
+
configure_git_hooks()
|
|
262
|
+
print(" Git hooks: post-commit + post-rewrite installed")
|
|
263
|
+
|
|
264
|
+
print()
|
|
265
|
+
print("agent-trace initialized. Everything runs locally.")
|
|
266
|
+
print()
|
|
267
|
+
print(" Change config: agent-trace reset")
|
|
268
|
+
print(" Add a sync remote: agent-trace remote add origin <url>")
|
|
269
|
+
print(" Push to remote: agent-trace push")
|
|
270
|
+
print(" Ship notes with commits: notes travel with 'git push' once a remote is set.")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _probe_remote_health(base_url: str) -> tuple[bool, str]:
|
|
274
|
+
"""Return (ok, detail) for an agent-trace HTTP service (tries /health, /api/health)."""
|
|
275
|
+
base = base_url.rstrip("/")
|
|
276
|
+
last_err = "unreachable"
|
|
277
|
+
for path in ("/health", "/api/health"):
|
|
278
|
+
try:
|
|
279
|
+
with urllib.request.urlopen(base + path, timeout=8) as resp:
|
|
280
|
+
if 200 <= getattr(resp, "status", 200) < 400:
|
|
281
|
+
return True, f"{path} -> OK"
|
|
282
|
+
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
|
283
|
+
last_err = str(e) or repr(e)
|
|
284
|
+
return False, last_err
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclasses.dataclass
|
|
288
|
+
class DoctorFixHints:
|
|
289
|
+
"""Auto-fix opportunities detected during doctor (never includes unreachable remotes)."""
|
|
290
|
+
|
|
291
|
+
chmod_home: bool = False
|
|
292
|
+
chmod_global_config: Path | None = None
|
|
293
|
+
init_needed: bool = False
|
|
294
|
+
global_hooks_needed: bool = False
|
|
295
|
+
git_hooks_needed: bool = False
|
|
296
|
+
git_rewrite_only: bool = False
|
|
297
|
+
notes_refspec_needed: bool = False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _doctor_git_has_origin(cwd: str) -> bool:
|
|
301
|
+
try:
|
|
302
|
+
r = subprocess.run(
|
|
303
|
+
["git", "-C", cwd, "remote", "get-url", "origin"],
|
|
304
|
+
capture_output=True,
|
|
305
|
+
text=True,
|
|
306
|
+
timeout=12,
|
|
307
|
+
)
|
|
308
|
+
return r.returncode == 0
|
|
309
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _doctor_diagnose(cwd: str) -> tuple[list[str], list[str], list[str], DoctorFixHints]:
|
|
314
|
+
"""Collect doctor messages and fix hints for ``cwd``."""
|
|
315
|
+
from .storage import (
|
|
316
|
+
get_agent_trace_home,
|
|
317
|
+
get_project_config_path,
|
|
318
|
+
resolve_project_id,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
ok: list[str] = []
|
|
322
|
+
warn: list[str] = []
|
|
323
|
+
err: list[str] = []
|
|
324
|
+
hints = DoctorFixHints()
|
|
325
|
+
|
|
326
|
+
def _git_out(args: list[str]) -> str:
|
|
327
|
+
try:
|
|
328
|
+
r = subprocess.run(
|
|
329
|
+
["git", "-C", cwd, *args],
|
|
330
|
+
capture_output=True,
|
|
331
|
+
text=True,
|
|
332
|
+
timeout=12,
|
|
333
|
+
)
|
|
334
|
+
return r.stdout if r.returncode == 0 else ""
|
|
335
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
336
|
+
return ""
|
|
337
|
+
|
|
338
|
+
home = get_agent_trace_home()
|
|
339
|
+
try:
|
|
340
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
probe = home / ".doctor_write_probe"
|
|
342
|
+
probe.write_text("")
|
|
343
|
+
try:
|
|
344
|
+
probe.unlink()
|
|
345
|
+
except OSError:
|
|
346
|
+
pass
|
|
347
|
+
ok.append(f"Global storage is writable ({home})")
|
|
348
|
+
except OSError as e:
|
|
349
|
+
err.append(f"Global storage not writable ({home}): {e}")
|
|
350
|
+
if home.exists():
|
|
351
|
+
hints.chmod_home = True
|
|
352
|
+
|
|
353
|
+
gf = get_global_config_file()
|
|
354
|
+
if gf.is_file():
|
|
355
|
+
mode = gf.stat().st_mode & 0o777
|
|
356
|
+
if mode & 0o077:
|
|
357
|
+
warn.append(f"Global config should be mode 600 (currently {oct(mode)})")
|
|
358
|
+
hints.chmod_global_config = gf
|
|
359
|
+
else:
|
|
360
|
+
ok.append("Global config permissions look good (600)")
|
|
361
|
+
|
|
362
|
+
cfg = get_project_config()
|
|
363
|
+
pid = resolve_project_id(cwd, create=False)
|
|
364
|
+
initialised = cfg is not None
|
|
365
|
+
|
|
366
|
+
if initialised:
|
|
367
|
+
if pid:
|
|
368
|
+
ok.append(f"Project config readable ({get_project_config_path(pid)})")
|
|
369
|
+
else:
|
|
370
|
+
ok.append("Project config readable")
|
|
371
|
+
else:
|
|
372
|
+
warn.append("Project not initialised (run agent-trace init)")
|
|
373
|
+
hints.init_needed = True
|
|
374
|
+
|
|
375
|
+
for adapter in iter_adapters():
|
|
376
|
+
label = adapter.display_name or adapter.name
|
|
377
|
+
global_path = adapter.global_config_path()
|
|
378
|
+
if adapter.is_installed():
|
|
379
|
+
ok.append(f"{label} global hooks configured ({global_path})")
|
|
380
|
+
continue
|
|
381
|
+
project_paths = _project_hook_paths_for(adapter)
|
|
382
|
+
any_present = False
|
|
383
|
+
for project_path in project_paths:
|
|
384
|
+
if os.path.exists(project_path):
|
|
385
|
+
any_present = True
|
|
386
|
+
try:
|
|
387
|
+
raw = open(project_path, encoding="utf-8").read()
|
|
388
|
+
except OSError:
|
|
389
|
+
warn.append(f"Could not read {project_path}")
|
|
390
|
+
continue
|
|
391
|
+
if AGENT_TRACE_CMD in raw:
|
|
392
|
+
ok.append(f"{label} project hooks mention {AGENT_TRACE_CMD} ({project_path})")
|
|
393
|
+
else:
|
|
394
|
+
warn.append(
|
|
395
|
+
f"{label} {project_path} present but no {AGENT_TRACE_CMD} hook found",
|
|
396
|
+
)
|
|
397
|
+
break
|
|
398
|
+
if not any_present and initialised:
|
|
399
|
+
warn.append(f"{label} hooks not configured (no global or project hooks)")
|
|
400
|
+
hints.global_hooks_needed = True
|
|
401
|
+
|
|
402
|
+
git_hook_ok = False
|
|
403
|
+
git_rewrite_ok = False
|
|
404
|
+
try:
|
|
405
|
+
if os.path.exists(".git/hooks/post-commit"):
|
|
406
|
+
with open(".git/hooks/post-commit", encoding="utf-8", errors="replace") as f:
|
|
407
|
+
git_hook_ok = "agent-trace commit-link" in f.read()
|
|
408
|
+
except OSError:
|
|
409
|
+
pass
|
|
410
|
+
try:
|
|
411
|
+
if os.path.exists(".git/hooks/post-rewrite"):
|
|
412
|
+
with open(".git/hooks/post-rewrite", encoding="utf-8", errors="replace") as f:
|
|
413
|
+
git_rewrite_ok = "agent-trace rewrite-ledger" in f.read()
|
|
414
|
+
except OSError:
|
|
415
|
+
pass
|
|
416
|
+
if git_hook_ok:
|
|
417
|
+
ok.append("Git post-commit hook calls agent-trace commit-link")
|
|
418
|
+
elif initialised and os.path.isdir(".git"):
|
|
419
|
+
warn.append("Git post-commit hook missing or does not call agent-trace commit-link")
|
|
420
|
+
hints.git_hooks_needed = True
|
|
421
|
+
if git_rewrite_ok:
|
|
422
|
+
ok.append("Git post-rewrite hook calls agent-trace rewrite-ledger")
|
|
423
|
+
elif initialised and os.path.isdir(".git"):
|
|
424
|
+
warn.append("Git post-rewrite hook missing or does not call agent-trace rewrite-ledger")
|
|
425
|
+
if git_hook_ok:
|
|
426
|
+
hints.git_rewrite_only = True
|
|
427
|
+
|
|
428
|
+
if os.path.isdir(".git"):
|
|
429
|
+
fetch_all = _git_out(["config", "--get-all", "remote.origin.fetch"])
|
|
430
|
+
push_all = _git_out(["config", "--get-all", "remote.origin.push"])
|
|
431
|
+
blob = f"{fetch_all}\n{push_all}"
|
|
432
|
+
if "refs/notes/agent-trace" in blob:
|
|
433
|
+
ok.append("Git notes refspec present for origin (refs/notes/agent-trace)")
|
|
434
|
+
else:
|
|
435
|
+
warn.append(
|
|
436
|
+
"Git notes refspec not configured for origin "
|
|
437
|
+
"(optional; run init or git config --add remote.origin.fetch +refs/notes/agent-trace:...)",
|
|
438
|
+
)
|
|
439
|
+
if _doctor_git_has_origin(cwd):
|
|
440
|
+
hints.notes_refspec_needed = True
|
|
441
|
+
|
|
442
|
+
if pid:
|
|
443
|
+
from .remote import (
|
|
444
|
+
RemoteUrlError,
|
|
445
|
+
TokenScopeError,
|
|
446
|
+
WhoamiUnsupportedError,
|
|
447
|
+
assert_token_matches_url,
|
|
448
|
+
get_remote as _doctor_get_remote,
|
|
449
|
+
get_remote_base_url,
|
|
450
|
+
get_remote_org_slug,
|
|
451
|
+
get_remote_project_slug,
|
|
452
|
+
get_remote_token,
|
|
453
|
+
parse_remote_url,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
remotes = remote_list(pid)
|
|
457
|
+
if not remotes:
|
|
458
|
+
ok.append("No sync remotes configured (optional: agent-trace remote add)")
|
|
459
|
+
for r in remotes:
|
|
460
|
+
url = r.get("url") or ""
|
|
461
|
+
name = r.get("name", "?")
|
|
462
|
+
if not url:
|
|
463
|
+
warn.append(f"Remote '{name}' has no URL")
|
|
464
|
+
continue
|
|
465
|
+
try:
|
|
466
|
+
parse_remote_url(url)
|
|
467
|
+
except RemoteUrlError as e:
|
|
468
|
+
err.append(
|
|
469
|
+
f"Remote '{name}' URL is malformed: {e}. "
|
|
470
|
+
f"Run `agent-trace remote set-url {name} <scheme>://<host>/<org>/<project>`."
|
|
471
|
+
)
|
|
472
|
+
continue
|
|
473
|
+
conf = _doctor_get_remote(pid, name) or {}
|
|
474
|
+
base_url = get_remote_base_url(conf)
|
|
475
|
+
org = get_remote_org_slug(conf) or "?"
|
|
476
|
+
proj = get_remote_project_slug(conf) or "?"
|
|
477
|
+
alive, detail = _probe_remote_health(base_url)
|
|
478
|
+
if alive:
|
|
479
|
+
ok.append(f"Remote '{name}' responds at {base_url} (org={org}, project={proj}) ({detail})")
|
|
480
|
+
else:
|
|
481
|
+
warn.append(f"Remote '{name}' not reachable at {base_url} ({detail})")
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
# Token / org / project scope check. Skip silently if no token
|
|
485
|
+
# or if the URL didn't carry the expected slugs (the malformed
|
|
486
|
+
# URL branch above already reported it). On a working scope we
|
|
487
|
+
# add a positive line so the user can see the binding is sound.
|
|
488
|
+
token = get_remote_token(conf)
|
|
489
|
+
if not token:
|
|
490
|
+
warn.append(f"Remote '{name}' has no auth token configured (scope check skipped)")
|
|
491
|
+
continue
|
|
492
|
+
url_org = get_remote_org_slug(conf)
|
|
493
|
+
url_project = get_remote_project_slug(conf)
|
|
494
|
+
if not url_org or not url_project:
|
|
495
|
+
continue
|
|
496
|
+
try:
|
|
497
|
+
info = assert_token_matches_url(
|
|
498
|
+
base_url, token,
|
|
499
|
+
expected_org_slug=url_org,
|
|
500
|
+
expected_project_slug=url_project,
|
|
501
|
+
allow_unsupported=True,
|
|
502
|
+
)
|
|
503
|
+
except TokenScopeError as e:
|
|
504
|
+
err.append(
|
|
505
|
+
f"Remote '{name}' token/scope mismatch ({e.code}): {e} "
|
|
506
|
+
f"Run `agent-trace remote set-token {name} ...` with a token "
|
|
507
|
+
f"for org '{url_org}', or `agent-trace remote set-url {name} ...` "
|
|
508
|
+
f"to point at the org the token belongs to."
|
|
509
|
+
)
|
|
510
|
+
continue
|
|
511
|
+
if info.get("_unsupported"):
|
|
512
|
+
warn.append(
|
|
513
|
+
f"Remote '{name}' service does not implement /auth/whoami "
|
|
514
|
+
"(upgrade the service to enable strict scope checks)"
|
|
515
|
+
)
|
|
516
|
+
else:
|
|
517
|
+
token_org = info.get("org_slug") or "?"
|
|
518
|
+
token_proj_scope = info.get("project_id_scope")
|
|
519
|
+
scope_desc = (
|
|
520
|
+
f"project-scoped to '{token_proj_scope}'"
|
|
521
|
+
if token_proj_scope else "org-scoped"
|
|
522
|
+
)
|
|
523
|
+
ok.append(
|
|
524
|
+
f"Remote '{name}' token matches URL "
|
|
525
|
+
f"(token org='{token_org}', {scope_desc})"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
summ = (cfg or {}).get("summary") if cfg else None
|
|
529
|
+
if isinstance(summ, dict) and summ.get("enabled"):
|
|
530
|
+
cmd = summ.get("command") or ""
|
|
531
|
+
parts = shlex.split(cmd) if cmd.strip() else []
|
|
532
|
+
if not parts:
|
|
533
|
+
err.append("summary.enabled but summary.command is empty")
|
|
534
|
+
else:
|
|
535
|
+
exe = parts[0]
|
|
536
|
+
if os.path.isfile(exe) or shutil.which(exe):
|
|
537
|
+
ok.append("Summary command first token is executable or on PATH")
|
|
538
|
+
else:
|
|
539
|
+
warn.append(f"Summary command may not run (not found: {exe})")
|
|
540
|
+
|
|
541
|
+
return ok, warn, err, hints
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def cmd_doctor(args):
|
|
545
|
+
"""Report hook installation, config validity, remotes, and optional tools."""
|
|
546
|
+
cwd = os.getcwd()
|
|
547
|
+
fix = getattr(args, "fix", False)
|
|
548
|
+
dry_run = getattr(args, "dry_run", False)
|
|
549
|
+
yes = getattr(args, "yes", False)
|
|
550
|
+
|
|
551
|
+
def _print_report(ok: list[str], warn: list[str], err: list[str]) -> None:
|
|
552
|
+
print("agent-trace doctor\n")
|
|
553
|
+
for line in ok:
|
|
554
|
+
print(f" OK {line}")
|
|
555
|
+
for line in warn:
|
|
556
|
+
print(f" !! {line}")
|
|
557
|
+
for line in err:
|
|
558
|
+
print(f" XX {line}")
|
|
559
|
+
print()
|
|
560
|
+
|
|
561
|
+
def _apply_fixes(hints: DoctorFixHints) -> list[str]:
|
|
562
|
+
"""Return human-readable lines describing actions taken (no-op if dry_run)."""
|
|
563
|
+
from .storage import get_agent_trace_home
|
|
564
|
+
|
|
565
|
+
lines: list[str] = []
|
|
566
|
+
home = get_agent_trace_home()
|
|
567
|
+
|
|
568
|
+
if hints.chmod_home and home.exists():
|
|
569
|
+
msg = f"chmod u+rwx {home}"
|
|
570
|
+
if dry_run:
|
|
571
|
+
lines.append(f"[dry-run] would: {msg}")
|
|
572
|
+
else:
|
|
573
|
+
try:
|
|
574
|
+
os.chmod(home, stat.S_IRWXU)
|
|
575
|
+
lines.append(f"Applied: {msg}")
|
|
576
|
+
except OSError as e:
|
|
577
|
+
lines.append(f"Skipped chmod on {home}: {e}")
|
|
578
|
+
|
|
579
|
+
if hints.chmod_global_config is not None:
|
|
580
|
+
p = hints.chmod_global_config
|
|
581
|
+
msg = f"chmod 600 {p}"
|
|
582
|
+
if dry_run:
|
|
583
|
+
lines.append(f"[dry-run] would: {msg}")
|
|
584
|
+
else:
|
|
585
|
+
try:
|
|
586
|
+
os.chmod(p, 0o600)
|
|
587
|
+
lines.append(f"Applied: {msg}")
|
|
588
|
+
except OSError as e:
|
|
589
|
+
lines.append(f"Skipped chmod on {p}: {e}")
|
|
590
|
+
|
|
591
|
+
if hints.init_needed:
|
|
592
|
+
if dry_run:
|
|
593
|
+
lines.append("[dry-run] would: agent-trace init")
|
|
594
|
+
elif yes:
|
|
595
|
+
cmd_init(args)
|
|
596
|
+
lines.append("Applied: agent-trace init (--yes)")
|
|
597
|
+
elif sys.stdin.isatty():
|
|
598
|
+
if _confirm("Initialize agent-trace for this project?", default=True):
|
|
599
|
+
cmd_init(args)
|
|
600
|
+
lines.append("Applied: agent-trace init (confirmed)")
|
|
601
|
+
else:
|
|
602
|
+
lines.append("Skipped: agent-trace init (not confirmed)")
|
|
603
|
+
else:
|
|
604
|
+
lines.append("Skipped: agent-trace init (non-interactive; use --yes to apply)")
|
|
605
|
+
|
|
606
|
+
if hints.global_hooks_needed:
|
|
607
|
+
msg = "hooks setup-global (all registered adapters)"
|
|
608
|
+
if dry_run:
|
|
609
|
+
lines.append(f"[dry-run] would: agent-trace {msg}")
|
|
610
|
+
else:
|
|
611
|
+
setup_global_hooks()
|
|
612
|
+
lines.append(f"Applied: agent-trace {msg}")
|
|
613
|
+
|
|
614
|
+
if hints.git_hooks_needed:
|
|
615
|
+
msg = "install git post-commit + post-rewrite hooks"
|
|
616
|
+
if dry_run:
|
|
617
|
+
lines.append(f"[dry-run] would: {msg}")
|
|
618
|
+
else:
|
|
619
|
+
configure_git_hooks(cwd)
|
|
620
|
+
lines.append(f"Applied: {msg}")
|
|
621
|
+
elif hints.git_rewrite_only:
|
|
622
|
+
msg = "install git post-rewrite hook"
|
|
623
|
+
if dry_run:
|
|
624
|
+
lines.append(f"[dry-run] would: {msg}")
|
|
625
|
+
else:
|
|
626
|
+
configure_git_post_rewrite_hook(cwd)
|
|
627
|
+
lines.append(f"Applied: {msg}")
|
|
628
|
+
|
|
629
|
+
if hints.notes_refspec_needed:
|
|
630
|
+
msg = "configure git notes refspec for remote.origin"
|
|
631
|
+
if dry_run:
|
|
632
|
+
lines.append(f"[dry-run] would: {msg}")
|
|
633
|
+
else:
|
|
634
|
+
if configure_git_notes_refspecs(project_dir=cwd, remote_name="origin"):
|
|
635
|
+
lines.append(f"Applied: {msg}")
|
|
636
|
+
else:
|
|
637
|
+
lines.append(f"Skipped: {msg} (git refused or no origin)")
|
|
638
|
+
|
|
639
|
+
return lines
|
|
640
|
+
|
|
641
|
+
ok, warn, err, hints = _doctor_diagnose(cwd)
|
|
642
|
+
|
|
643
|
+
if fix:
|
|
644
|
+
applied_any = any(
|
|
645
|
+
(
|
|
646
|
+
hints.chmod_home,
|
|
647
|
+
hints.chmod_global_config is not None,
|
|
648
|
+
hints.init_needed,
|
|
649
|
+
hints.global_hooks_needed,
|
|
650
|
+
hints.git_hooks_needed,
|
|
651
|
+
hints.git_rewrite_only,
|
|
652
|
+
hints.notes_refspec_needed,
|
|
653
|
+
),
|
|
654
|
+
)
|
|
655
|
+
if applied_any:
|
|
656
|
+
print("agent-trace doctor --fix\n")
|
|
657
|
+
action_lines = _apply_fixes(hints)
|
|
658
|
+
for line in sorted(action_lines):
|
|
659
|
+
print(f" {line}")
|
|
660
|
+
print()
|
|
661
|
+
if not dry_run:
|
|
662
|
+
ok, warn, err, _hints = _doctor_diagnose(cwd)
|
|
663
|
+
|
|
664
|
+
_print_report(ok, warn, err)
|
|
665
|
+
if err:
|
|
666
|
+
sys.exit(1)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ===================================================================
|
|
670
|
+
# status
|
|
671
|
+
# ===================================================================
|
|
672
|
+
|
|
673
|
+
def cmd_status(_args):
|
|
674
|
+
config = get_project_config()
|
|
675
|
+
if config is None:
|
|
676
|
+
print("agent-trace is not set up for this project.")
|
|
677
|
+
print("Run 'agent-trace init' to get started.")
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
from .storage import (
|
|
681
|
+
get_commit_links_path,
|
|
682
|
+
get_ledgers_path,
|
|
683
|
+
get_project_dir,
|
|
684
|
+
get_traces_path,
|
|
685
|
+
resolve_project_id,
|
|
686
|
+
)
|
|
687
|
+
pid = resolve_project_id(os.getcwd(), create=False)
|
|
688
|
+
|
|
689
|
+
print("agent-trace status\n")
|
|
690
|
+
if pid:
|
|
691
|
+
print(f" Project: {pid}")
|
|
692
|
+
print(f" Data dir: {get_project_dir(pid)}")
|
|
693
|
+
|
|
694
|
+
def _count(path):
|
|
695
|
+
if not path.exists():
|
|
696
|
+
return 0
|
|
697
|
+
with open(path) as f:
|
|
698
|
+
return sum(1 for _ in f)
|
|
699
|
+
|
|
700
|
+
print(f" Traces: {_count(get_traces_path(pid))} recorded")
|
|
701
|
+
print(f" Commit links: {_count(get_commit_links_path(pid))} recorded")
|
|
702
|
+
print(f" Ledgers: {_count(get_ledgers_path(pid))} recorded")
|
|
703
|
+
|
|
704
|
+
# Show remote sync info
|
|
705
|
+
try:
|
|
706
|
+
report = sync_status(pid)
|
|
707
|
+
if report.remote_name:
|
|
708
|
+
print(f"\n Remote '{report.remote_name}' ({report.remote_url}):")
|
|
709
|
+
print(f" Unpushed: {report.unpushed_traces} traces "
|
|
710
|
+
f"({report.unattributed_traces} unattributed held back)")
|
|
711
|
+
print(f" {report.unpushed_ledgers} ledgers")
|
|
712
|
+
print(f" {report.unpushed_commit_links} commit-links")
|
|
713
|
+
if report.traces_cursor:
|
|
714
|
+
print(f" Traces cursor: {report.traces_cursor}")
|
|
715
|
+
if report.ledgers_cursor:
|
|
716
|
+
print(f" Ledgers cursor: {report.ledgers_cursor}")
|
|
717
|
+
if report.commit_links_cursor:
|
|
718
|
+
print(f" Commit-links cursor: {report.commit_links_cursor}")
|
|
719
|
+
if report.conversations_cursor:
|
|
720
|
+
print(f" Conversations cursor: {report.conversations_cursor}")
|
|
721
|
+
print()
|
|
722
|
+
print(" Run 'agent-trace push' to share attributed work.")
|
|
723
|
+
print(" Run 'agent-trace pull' to fetch teammates' changes.")
|
|
724
|
+
except Exception:
|
|
725
|
+
pass
|
|
726
|
+
|
|
727
|
+
git_hook_ok = False
|
|
728
|
+
git_rewrite_ok = False
|
|
729
|
+
try:
|
|
730
|
+
if os.path.exists(".git/hooks/post-commit"):
|
|
731
|
+
with open(".git/hooks/post-commit") as f:
|
|
732
|
+
git_hook_ok = "agent-trace commit-link" in f.read()
|
|
733
|
+
except OSError:
|
|
734
|
+
pass
|
|
735
|
+
try:
|
|
736
|
+
if os.path.exists(".git/hooks/post-rewrite"):
|
|
737
|
+
with open(".git/hooks/post-rewrite") as f:
|
|
738
|
+
git_rewrite_ok = "agent-trace rewrite-ledger" in f.read()
|
|
739
|
+
except OSError:
|
|
740
|
+
pass
|
|
741
|
+
|
|
742
|
+
def _hook_label(has_global, has_project):
|
|
743
|
+
if has_global:
|
|
744
|
+
return "global"
|
|
745
|
+
if has_project:
|
|
746
|
+
return "project"
|
|
747
|
+
return "not configured"
|
|
748
|
+
|
|
749
|
+
print()
|
|
750
|
+
for adapter in iter_adapters():
|
|
751
|
+
label = adapter.display_name or adapter.name
|
|
752
|
+
is_global = adapter.is_installed()
|
|
753
|
+
is_project = any(
|
|
754
|
+
os.path.exists(p) for p in _project_hook_paths_for(adapter)
|
|
755
|
+
)
|
|
756
|
+
print(f" {label} hook:".ljust(22) + _hook_label(is_global, is_project))
|
|
757
|
+
print(f" Git post-commit: {'configured' if git_hook_ok else 'not configured'}")
|
|
758
|
+
print(f" Git post-rewrite: {'configured' if git_rewrite_ok else 'not configured'}")
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# ===================================================================
|
|
762
|
+
# reset
|
|
763
|
+
# ===================================================================
|
|
764
|
+
|
|
765
|
+
def cmd_reset(_args):
|
|
766
|
+
"""Reconfigure notes sections, hooks, and git-notes refspecs."""
|
|
767
|
+
config = get_project_config()
|
|
768
|
+
if config is None:
|
|
769
|
+
print("agent-trace is not set up for this project.")
|
|
770
|
+
print("Run 'agent-trace init' to get started.")
|
|
771
|
+
return
|
|
772
|
+
|
|
773
|
+
print("Resetting agent-trace configuration...\n")
|
|
774
|
+
|
|
775
|
+
notes_cfg = config.get("notes") or {}
|
|
776
|
+
notes_enabled = _confirm(
|
|
777
|
+
"Enable git notes (JSON on refs/notes/agent-trace)?",
|
|
778
|
+
default=bool(notes_cfg.get("enabled", True)),
|
|
779
|
+
)
|
|
780
|
+
new_notes = {"enabled": notes_enabled}
|
|
781
|
+
if notes_enabled:
|
|
782
|
+
new_notes["include_ledger"] = _confirm(
|
|
783
|
+
"Include per-line ledger in notes?",
|
|
784
|
+
default=bool(notes_cfg.get("include_ledger", False)),
|
|
785
|
+
)
|
|
786
|
+
new_notes["include_summary"] = _confirm(
|
|
787
|
+
"Include summaries in notes?",
|
|
788
|
+
default=bool(notes_cfg.get("include_summary", True)),
|
|
789
|
+
)
|
|
790
|
+
new_notes["include_prompts"] = _confirm(
|
|
791
|
+
"Include prompt previews in notes?",
|
|
792
|
+
default=bool(notes_cfg.get("include_prompts", True)),
|
|
793
|
+
)
|
|
794
|
+
new_notes["all_session_conversations"] = _confirm(
|
|
795
|
+
"Include all session conversations in notes (staging window, not only attributed lines)?",
|
|
796
|
+
default=bool(notes_cfg.get("all_session_conversations", False)),
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
new_config = dict(config)
|
|
800
|
+
new_config.pop("label", None)
|
|
801
|
+
new_config["notes"] = new_notes
|
|
802
|
+
save_project_config(new_config)
|
|
803
|
+
print("\nConfiguration updated.\n")
|
|
804
|
+
|
|
805
|
+
if os.path.isdir(".git"):
|
|
806
|
+
rn = _prompt("Git remote for note refspecs (blank to skip)", default="origin").strip()
|
|
807
|
+
if rn:
|
|
808
|
+
if configure_git_notes_refspecs(remote_name=rn):
|
|
809
|
+
print(f" -> Git notes refspecs configured for remote \"{rn}\"")
|
|
810
|
+
else:
|
|
811
|
+
print(f" -> Skipped (no remote \"{rn}\")")
|
|
812
|
+
|
|
813
|
+
for adapter in iter_adapters():
|
|
814
|
+
label = adapter.display_name or adapter.name
|
|
815
|
+
if _confirm(f"Reconfigure {label} hook?", default=False):
|
|
816
|
+
adapter.inject(AGENT_TRACE_CMD, global_install=False)
|
|
817
|
+
print(f" -> {label} hooks configured.")
|
|
818
|
+
if os.path.isdir(".git") and _confirm("Reinstall git hooks?", default=False):
|
|
819
|
+
configure_git_hooks()
|
|
820
|
+
print(" -> Git hooks reinstalled.")
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# ===================================================================
|
|
824
|
+
# config
|
|
825
|
+
# ===================================================================
|
|
826
|
+
|
|
827
|
+
_DEFAULT_NOTES_CONFIG = {
|
|
828
|
+
"enabled": True,
|
|
829
|
+
"include_ledger": False,
|
|
830
|
+
"include_summary": True,
|
|
831
|
+
"include_prompts": True,
|
|
832
|
+
"all_session_conversations": False,
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
_CONFIG_FIELDS = {
|
|
836
|
+
"notes.enabled",
|
|
837
|
+
"notes.include-ledger",
|
|
838
|
+
"notes.include-summary",
|
|
839
|
+
"notes.include-prompts",
|
|
840
|
+
"notes.all-session-conversations",
|
|
841
|
+
"summary.enabled",
|
|
842
|
+
"summary.command",
|
|
843
|
+
"summary.timeout-seconds",
|
|
844
|
+
"remote.default",
|
|
845
|
+
"global.auth-token",
|
|
846
|
+
"global.capture-detached-edits",
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
_CONFIG_RESET_TARGETS = _CONFIG_FIELDS | {"notes", "summary"}
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _parse_config_bool(value: str) -> bool:
|
|
853
|
+
v = str(value).strip().lower()
|
|
854
|
+
if v in {"1", "true", "yes", "y", "on", "enable", "enabled"}:
|
|
855
|
+
return True
|
|
856
|
+
if v in {"0", "false", "no", "n", "off", "disable", "disabled"}:
|
|
857
|
+
return False
|
|
858
|
+
raise ValueError("expected a boolean value (true/false)")
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _redact_config_value(value):
|
|
862
|
+
if isinstance(value, dict):
|
|
863
|
+
redacted = {}
|
|
864
|
+
for key, val in value.items():
|
|
865
|
+
lk = str(key).lower()
|
|
866
|
+
if lk == "install_id" and val:
|
|
867
|
+
redacted[key] = "(anonymous id)"
|
|
868
|
+
elif lk in {"auth_token", "token"} or "secret" in lk:
|
|
869
|
+
redacted[key] = "(set)" if val else val
|
|
870
|
+
elif lk == "tokens" and isinstance(val, dict):
|
|
871
|
+
redacted[key] = {name: "(set)" for name in val}
|
|
872
|
+
else:
|
|
873
|
+
redacted[key] = _redact_config_value(val)
|
|
874
|
+
return redacted
|
|
875
|
+
if isinstance(value, list):
|
|
876
|
+
return [_redact_config_value(item) for item in value]
|
|
877
|
+
return value
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _project_config_or_exit() -> tuple[str, dict]:
|
|
881
|
+
from .storage import resolve_project_id
|
|
882
|
+
|
|
883
|
+
cfg = get_project_config()
|
|
884
|
+
if cfg is None:
|
|
885
|
+
print("agent-trace is not set up for this project.", file=sys.stderr)
|
|
886
|
+
print("Run 'agent-trace init' to get started.", file=sys.stderr)
|
|
887
|
+
sys.exit(1)
|
|
888
|
+
pid = resolve_project_id(os.getcwd(), create=False)
|
|
889
|
+
if not pid:
|
|
890
|
+
print("agent-trace config: no project id", file=sys.stderr)
|
|
891
|
+
sys.exit(1)
|
|
892
|
+
return pid, cfg
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _load_safe_remotes(project_id: str) -> dict:
|
|
896
|
+
try:
|
|
897
|
+
from .remote import _load_remotes
|
|
898
|
+
except ImportError:
|
|
899
|
+
return {}
|
|
900
|
+
return _redact_config_value(_load_remotes(project_id))
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _hook_config_status() -> dict:
|
|
904
|
+
git_post_commit = False
|
|
905
|
+
git_post_rewrite = False
|
|
906
|
+
try:
|
|
907
|
+
if os.path.exists(".git/hooks/post-commit"):
|
|
908
|
+
with open(".git/hooks/post-commit") as f:
|
|
909
|
+
git_post_commit = "agent-trace commit-link" in f.read()
|
|
910
|
+
except OSError:
|
|
911
|
+
pass
|
|
912
|
+
try:
|
|
913
|
+
if os.path.exists(".git/hooks/post-rewrite"):
|
|
914
|
+
with open(".git/hooks/post-rewrite") as f:
|
|
915
|
+
git_post_rewrite = "agent-trace rewrite-ledger" in f.read()
|
|
916
|
+
except OSError:
|
|
917
|
+
pass
|
|
918
|
+
out: dict = {}
|
|
919
|
+
for adapter in iter_adapters():
|
|
920
|
+
out[adapter.name] = {
|
|
921
|
+
"global": adapter.is_installed(),
|
|
922
|
+
"project": any(
|
|
923
|
+
os.path.exists(p) for p in _project_hook_paths_for(adapter)
|
|
924
|
+
),
|
|
925
|
+
}
|
|
926
|
+
out["git"] = {
|
|
927
|
+
"post_commit": git_post_commit,
|
|
928
|
+
"post_rewrite": git_post_rewrite,
|
|
929
|
+
}
|
|
930
|
+
return out
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _full_config_snapshot() -> dict:
|
|
934
|
+
from .storage import get_project_dir, resolve_project_id
|
|
935
|
+
|
|
936
|
+
cfg = get_project_config()
|
|
937
|
+
pid = resolve_project_id(os.getcwd(), create=False)
|
|
938
|
+
snapshot = {
|
|
939
|
+
"global": {
|
|
940
|
+
"config": _redact_config_value(get_global_config()),
|
|
941
|
+
},
|
|
942
|
+
"project": None,
|
|
943
|
+
"hooks": _hook_config_status(),
|
|
944
|
+
}
|
|
945
|
+
if pid and cfg is not None:
|
|
946
|
+
snapshot["project"] = {
|
|
947
|
+
"id": pid,
|
|
948
|
+
"data_dir": str(get_project_dir(pid)),
|
|
949
|
+
"config": cfg,
|
|
950
|
+
"remotes": _load_safe_remotes(pid),
|
|
951
|
+
}
|
|
952
|
+
return snapshot
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _print_config_snapshot(snapshot: dict) -> None:
|
|
956
|
+
print("agent-trace config\n")
|
|
957
|
+
project = snapshot.get("project")
|
|
958
|
+
if project:
|
|
959
|
+
print(f" Project: {project['id']}")
|
|
960
|
+
print(f" Data dir: {project['data_dir']}")
|
|
961
|
+
print("\n Project config:")
|
|
962
|
+
print(_indent_json(project.get("config") or {}))
|
|
963
|
+
print("\n Remotes:")
|
|
964
|
+
print(_indent_json(project.get("remotes") or {}))
|
|
965
|
+
else:
|
|
966
|
+
print(" Project: not initialized")
|
|
967
|
+
|
|
968
|
+
print("\n Global config:")
|
|
969
|
+
print(_indent_json(snapshot.get("global", {}).get("config") or {}))
|
|
970
|
+
print("\n Hooks:")
|
|
971
|
+
print(_indent_json(snapshot.get("hooks") or {}))
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _indent_json(value: dict) -> str:
|
|
975
|
+
text = json.dumps(value, indent=2, sort_keys=True)
|
|
976
|
+
return "\n".join(f" {line}" for line in text.splitlines())
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _cleanup_empty_mapping(config: dict, key: str) -> None:
|
|
980
|
+
if isinstance(config.get(key), dict) and not config[key]:
|
|
981
|
+
del config[key]
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _bool_to_text(value: bool) -> str:
|
|
985
|
+
return "true" if value else "false"
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _prompt_reset_bool(field: str, default_value: bool) -> bool:
|
|
989
|
+
raw = _prompt(
|
|
990
|
+
f"Reset {field} (press Enter for default)",
|
|
991
|
+
default=_bool_to_text(default_value),
|
|
992
|
+
)
|
|
993
|
+
return _parse_config_bool(raw)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _interactive_reset_field(field: str) -> None:
|
|
997
|
+
"""Interactive reset flow: Enter accepts reset defaults."""
|
|
998
|
+
bool_defaults = {
|
|
999
|
+
"notes.enabled": True,
|
|
1000
|
+
"notes.include-ledger": False,
|
|
1001
|
+
"notes.include-summary": True,
|
|
1002
|
+
"notes.include-prompts": True,
|
|
1003
|
+
"notes.all-session-conversations": False,
|
|
1004
|
+
"summary.enabled": False,
|
|
1005
|
+
"global.capture-detached-edits": False,
|
|
1006
|
+
}
|
|
1007
|
+
clear_targets = {
|
|
1008
|
+
"summary",
|
|
1009
|
+
"summary.command",
|
|
1010
|
+
"summary.timeout-seconds",
|
|
1011
|
+
"remote.default",
|
|
1012
|
+
"global.auth-token",
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if field == "notes":
|
|
1016
|
+
_pid, cfg = _project_config_or_exit()
|
|
1017
|
+
cfg["notes"] = {
|
|
1018
|
+
"enabled": _prompt_reset_bool("notes.enabled", True),
|
|
1019
|
+
"include_ledger": _prompt_reset_bool("notes.include-ledger", False),
|
|
1020
|
+
"include_summary": _prompt_reset_bool("notes.include-summary", True),
|
|
1021
|
+
"include_prompts": _prompt_reset_bool("notes.include-prompts", True),
|
|
1022
|
+
"all_session_conversations": _prompt_reset_bool("notes.all-session-conversations", False),
|
|
1023
|
+
}
|
|
1024
|
+
save_project_config(cfg)
|
|
1025
|
+
return
|
|
1026
|
+
|
|
1027
|
+
if field in bool_defaults:
|
|
1028
|
+
chosen = _prompt_reset_bool(field, bool_defaults[field])
|
|
1029
|
+
_set_config_field(field, _bool_to_text(chosen))
|
|
1030
|
+
return
|
|
1031
|
+
|
|
1032
|
+
if field in clear_targets:
|
|
1033
|
+
if not _confirm(f"Reset {field} to default (clear current value)?", default=True):
|
|
1034
|
+
print("No changes made.")
|
|
1035
|
+
return
|
|
1036
|
+
_reset_config_field(field)
|
|
1037
|
+
return
|
|
1038
|
+
|
|
1039
|
+
# Fallback: if new reset targets are added in future.
|
|
1040
|
+
_reset_config_field(field)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _set_config_field(field: str, value: str) -> None:
|
|
1044
|
+
if field not in _CONFIG_FIELDS:
|
|
1045
|
+
raise ValueError(f"unknown field '{field}'")
|
|
1046
|
+
|
|
1047
|
+
if field.startswith("global."):
|
|
1048
|
+
cfg = get_global_config()
|
|
1049
|
+
if field == "global.auth-token":
|
|
1050
|
+
cfg["auth_token"] = value
|
|
1051
|
+
elif field == "global.capture-detached-edits":
|
|
1052
|
+
cfg["capture_detached_edits"] = _parse_config_bool(value)
|
|
1053
|
+
save_global_config(cfg)
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
pid, cfg = _project_config_or_exit()
|
|
1057
|
+
if field.startswith("notes."):
|
|
1058
|
+
notes = cfg.setdefault("notes", {})
|
|
1059
|
+
key = field.split(".", 1)[1].replace("-", "_")
|
|
1060
|
+
notes[key] = _parse_config_bool(value)
|
|
1061
|
+
elif field == "summary.enabled":
|
|
1062
|
+
sm = cfg.setdefault("summary", {})
|
|
1063
|
+
sm["enabled"] = _parse_config_bool(value)
|
|
1064
|
+
elif field == "summary.command":
|
|
1065
|
+
if not value.strip():
|
|
1066
|
+
raise ValueError("summary.command cannot be blank")
|
|
1067
|
+
sm = cfg.setdefault("summary", {})
|
|
1068
|
+
sm["enabled"] = True
|
|
1069
|
+
sm["command"] = value
|
|
1070
|
+
elif field == "summary.timeout-seconds":
|
|
1071
|
+
timeout = int(value)
|
|
1072
|
+
if timeout <= 0:
|
|
1073
|
+
raise ValueError("summary.timeout-seconds must be positive")
|
|
1074
|
+
cfg.setdefault("summary", {})["timeout_seconds"] = timeout
|
|
1075
|
+
elif field == "remote.default":
|
|
1076
|
+
from .remote import set_default_remote
|
|
1077
|
+
|
|
1078
|
+
set_default_remote(pid, value)
|
|
1079
|
+
return
|
|
1080
|
+
save_project_config(cfg)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _reset_config_field(field: str) -> None:
|
|
1084
|
+
if field not in _CONFIG_RESET_TARGETS:
|
|
1085
|
+
raise ValueError(f"unknown reset target '{field}'")
|
|
1086
|
+
|
|
1087
|
+
if field.startswith("global."):
|
|
1088
|
+
cfg = get_global_config()
|
|
1089
|
+
if field == "global.auth-token":
|
|
1090
|
+
cfg.pop("auth_token", None)
|
|
1091
|
+
elif field == "global.capture-detached-edits":
|
|
1092
|
+
cfg.pop("capture_detached_edits", None)
|
|
1093
|
+
save_global_config(cfg)
|
|
1094
|
+
return
|
|
1095
|
+
|
|
1096
|
+
_pid, cfg = _project_config_or_exit()
|
|
1097
|
+
if field == "notes":
|
|
1098
|
+
cfg["notes"] = dict(_DEFAULT_NOTES_CONFIG)
|
|
1099
|
+
elif field.startswith("notes."):
|
|
1100
|
+
notes = cfg.setdefault("notes", {})
|
|
1101
|
+
key = field.split(".", 1)[1].replace("-", "_")
|
|
1102
|
+
notes[key] = _DEFAULT_NOTES_CONFIG[key]
|
|
1103
|
+
elif field == "summary":
|
|
1104
|
+
cfg.pop("summary", None)
|
|
1105
|
+
elif field == "summary.enabled":
|
|
1106
|
+
cfg.setdefault("summary", {})["enabled"] = False
|
|
1107
|
+
elif field == "summary.command":
|
|
1108
|
+
if isinstance(cfg.get("summary"), dict):
|
|
1109
|
+
cfg["summary"].pop("command", None)
|
|
1110
|
+
_cleanup_empty_mapping(cfg, "summary")
|
|
1111
|
+
elif field == "summary.timeout-seconds":
|
|
1112
|
+
if isinstance(cfg.get("summary"), dict):
|
|
1113
|
+
cfg["summary"].pop("timeout_seconds", None)
|
|
1114
|
+
_cleanup_empty_mapping(cfg, "summary")
|
|
1115
|
+
elif field == "remote.default":
|
|
1116
|
+
if isinstance(cfg.get("remote"), dict):
|
|
1117
|
+
cfg["remote"].pop("default", None)
|
|
1118
|
+
_cleanup_empty_mapping(cfg, "remote")
|
|
1119
|
+
save_project_config(cfg)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def cmd_config(args):
|
|
1123
|
+
"""Show and mutate persisted configuration."""
|
|
1124
|
+
action = getattr(args, "config_action", None)
|
|
1125
|
+
if action == "show":
|
|
1126
|
+
snapshot = _full_config_snapshot()
|
|
1127
|
+
if getattr(args, "json", False):
|
|
1128
|
+
print(json.dumps(snapshot, indent=2, sort_keys=True))
|
|
1129
|
+
else:
|
|
1130
|
+
_print_config_snapshot(snapshot)
|
|
1131
|
+
return
|
|
1132
|
+
|
|
1133
|
+
if action == "set":
|
|
1134
|
+
try:
|
|
1135
|
+
_set_config_field(args.field, args.value)
|
|
1136
|
+
except (ValueError, TypeError) as e:
|
|
1137
|
+
print(f"agent-trace config set: {e}", file=sys.stderr)
|
|
1138
|
+
sys.exit(1)
|
|
1139
|
+
print(f"Config field '{args.field}' updated.")
|
|
1140
|
+
return
|
|
1141
|
+
|
|
1142
|
+
if action == "reset":
|
|
1143
|
+
try:
|
|
1144
|
+
if getattr(args, "yes", False):
|
|
1145
|
+
_reset_config_field(args.field)
|
|
1146
|
+
else:
|
|
1147
|
+
_interactive_reset_field(args.field)
|
|
1148
|
+
except (ValueError, TypeError) as e:
|
|
1149
|
+
print(f"agent-trace config reset: {e}", file=sys.stderr)
|
|
1150
|
+
sys.exit(1)
|
|
1151
|
+
print(f"Config field '{args.field}' reset.")
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
print("Usage: agent-trace config {show,set,reset}")
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
# ===================================================================
|
|
1158
|
+
# hooks (global hook management)
|
|
1159
|
+
# ===================================================================
|
|
1160
|
+
|
|
1161
|
+
# Computed lazily from the adapter registry so newly registered tools
|
|
1162
|
+
# are picked up automatically by the ``--tool`` flag and by status output.
|
|
1163
|
+
def _hook_tool_choices() -> list[str]:
|
|
1164
|
+
return adapter_names()
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def cmd_hooks(args):
|
|
1168
|
+
"""Manage global hooks for coding tools."""
|
|
1169
|
+
action = getattr(args, "hooks_action", None)
|
|
1170
|
+
|
|
1171
|
+
if action == "setup-global":
|
|
1172
|
+
tools = getattr(args, "tools", None) or _hook_tool_choices()
|
|
1173
|
+
results = setup_global_hooks(tools)
|
|
1174
|
+
for tool, ok in results.items():
|
|
1175
|
+
if ok:
|
|
1176
|
+
print(f" -> Global {tool} hooks configured")
|
|
1177
|
+
else:
|
|
1178
|
+
print(f" !! Failed to configure global {tool} hooks")
|
|
1179
|
+
|
|
1180
|
+
elif action == "remove-global":
|
|
1181
|
+
tools = getattr(args, "tools", None) or _hook_tool_choices()
|
|
1182
|
+
results = remove_global_hooks(tools)
|
|
1183
|
+
for tool, removed in results.items():
|
|
1184
|
+
if removed:
|
|
1185
|
+
print(f" -> Global {tool} hooks removed")
|
|
1186
|
+
else:
|
|
1187
|
+
print(f" -- Global {tool} hooks were not present")
|
|
1188
|
+
|
|
1189
|
+
elif action == "status":
|
|
1190
|
+
print("Global hooks:")
|
|
1191
|
+
for adapter in iter_adapters():
|
|
1192
|
+
label = adapter.display_name or adapter.name
|
|
1193
|
+
state = "configured" if adapter.is_installed() else "not configured"
|
|
1194
|
+
print(f" {label:<12} {state:<16} ({adapter.global_config_path()})")
|
|
1195
|
+
|
|
1196
|
+
else:
|
|
1197
|
+
print("Usage: agent-trace hooks {setup-global,remove-global,status}")
|
|
1198
|
+
print("Run 'agent-trace hooks --help' for details.")
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
# ===================================================================
|
|
1202
|
+
# record (called by hooks — reads stdin)
|
|
1203
|
+
# ===================================================================
|
|
1204
|
+
|
|
1205
|
+
def cmd_record(_args):
|
|
1206
|
+
try:
|
|
1207
|
+
record_from_stdin()
|
|
1208
|
+
except Exception:
|
|
1209
|
+
# Never crash the coding agent
|
|
1210
|
+
pass
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
# ===================================================================
|
|
1214
|
+
# commit-link (called by git post-commit hook)
|
|
1215
|
+
# ===================================================================
|
|
1216
|
+
|
|
1217
|
+
def cmd_commit_link(_args):
|
|
1218
|
+
"""Create a commit-trace link for the current HEAD commit."""
|
|
1219
|
+
try:
|
|
1220
|
+
link = create_commit_link()
|
|
1221
|
+
if link:
|
|
1222
|
+
n = len(link.get("trace_ids", []))
|
|
1223
|
+
print(f"agent-trace: linked commit {link['commit_sha'][:8]} to {n} trace(s)")
|
|
1224
|
+
except Exception:
|
|
1225
|
+
# Never crash — this runs inside a git hook
|
|
1226
|
+
pass
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
# ===================================================================
|
|
1230
|
+
# rewrite-ledger (called by git post-rewrite hook)
|
|
1231
|
+
# ===================================================================
|
|
1232
|
+
|
|
1233
|
+
def cmd_rewrite_ledger(_args):
|
|
1234
|
+
"""Remap ledgers after rebase/amend (called by git post-rewrite hook)."""
|
|
1235
|
+
try:
|
|
1236
|
+
count = rewrite_ledgers()
|
|
1237
|
+
if count:
|
|
1238
|
+
print(f"agent-trace: remapped {count} ledger(s)")
|
|
1239
|
+
except Exception:
|
|
1240
|
+
# Never crash — this runs inside a git hook
|
|
1241
|
+
pass
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
# ===================================================================
|
|
1245
|
+
# viewer
|
|
1246
|
+
# ===================================================================
|
|
1247
|
+
|
|
1248
|
+
def cmd_viewer(args):
|
|
1249
|
+
"""Launch the file viewer, or print install instructions if not installed."""
|
|
1250
|
+
project_path = getattr(args, "project", None) or os.getcwd()
|
|
1251
|
+
if not os.path.isdir(project_path):
|
|
1252
|
+
print(f"agent-trace viewer: project path is not a directory: {project_path}", file=sys.stderr)
|
|
1253
|
+
sys.exit(1)
|
|
1254
|
+
|
|
1255
|
+
if not os.path.isfile(VIEWER_BIN) or not os.access(VIEWER_BIN, os.X_OK):
|
|
1256
|
+
print("Viewer is not installed.", file=sys.stderr)
|
|
1257
|
+
print("", file=sys.stderr)
|
|
1258
|
+
print("Re-run install.sh from the agent-trace-cli repo to install the viewer:", file=sys.stderr)
|
|
1259
|
+
print(" cd agent-trace-cli && ./install.sh", file=sys.stderr)
|
|
1260
|
+
sys.exit(1)
|
|
1261
|
+
|
|
1262
|
+
# Exec the viewer with project path as first argument
|
|
1263
|
+
os.execv(VIEWER_BIN, [VIEWER_BIN, project_path])
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
# ===================================================================
|
|
1267
|
+
# blame
|
|
1268
|
+
# ===================================================================
|
|
1269
|
+
|
|
1270
|
+
def cmd_blame(args):
|
|
1271
|
+
"""Show AI attribution for a file."""
|
|
1272
|
+
# Parse --range if provided (e.g. "10-25")
|
|
1273
|
+
start_line = None
|
|
1274
|
+
end_line = None
|
|
1275
|
+
if getattr(args, "range", None):
|
|
1276
|
+
parts = args.range.split("-", 1)
|
|
1277
|
+
try:
|
|
1278
|
+
start_line = int(parts[0])
|
|
1279
|
+
end_line = int(parts[1]) if len(parts) > 1 else start_line
|
|
1280
|
+
except (ValueError, IndexError):
|
|
1281
|
+
print(f"Invalid range: {args.range} (expected format: START-END)")
|
|
1282
|
+
sys.exit(1)
|
|
1283
|
+
|
|
1284
|
+
project_dir = None
|
|
1285
|
+
if getattr(args, "project", None):
|
|
1286
|
+
project_dir = cli_resolve_project_root(args.project)
|
|
1287
|
+
else:
|
|
1288
|
+
amb = discover_ambiguous_repo_roots()
|
|
1289
|
+
if len(amb) > 1:
|
|
1290
|
+
print(
|
|
1291
|
+
"agent-trace: current directory spans multiple git repositories; "
|
|
1292
|
+
"pass --project <path|id> to choose one:",
|
|
1293
|
+
file=sys.stderr,
|
|
1294
|
+
)
|
|
1295
|
+
for r in amb:
|
|
1296
|
+
print(f" {r}", file=sys.stderr)
|
|
1297
|
+
sys.exit(1)
|
|
1298
|
+
|
|
1299
|
+
result = blame_file(
|
|
1300
|
+
args.file,
|
|
1301
|
+
line=getattr(args, "line", None),
|
|
1302
|
+
start_line=start_line,
|
|
1303
|
+
end_line=end_line,
|
|
1304
|
+
show_no_attribution=getattr(args, "show_no_attribution", False),
|
|
1305
|
+
require_attribution=getattr(args, "require_attribution", False),
|
|
1306
|
+
json_output=getattr(args, "json", False),
|
|
1307
|
+
project_dir=project_dir,
|
|
1308
|
+
)
|
|
1309
|
+
if result is not None:
|
|
1310
|
+
print(result)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
# ===================================================================
|
|
1314
|
+
# context
|
|
1315
|
+
# ===================================================================
|
|
1316
|
+
|
|
1317
|
+
def cmd_context(args):
|
|
1318
|
+
"""Get conversation context for AI-attributed code."""
|
|
1319
|
+
project_dir = None
|
|
1320
|
+
if getattr(args, "project", None):
|
|
1321
|
+
project_dir = cli_resolve_project_root(args.project)
|
|
1322
|
+
else:
|
|
1323
|
+
amb = discover_ambiguous_repo_roots()
|
|
1324
|
+
if len(amb) > 1:
|
|
1325
|
+
print(
|
|
1326
|
+
"agent-trace: current directory spans multiple git repositories; "
|
|
1327
|
+
"pass --project <path|id>:",
|
|
1328
|
+
file=sys.stderr,
|
|
1329
|
+
)
|
|
1330
|
+
for r in amb:
|
|
1331
|
+
print(f" {r}", file=sys.stderr)
|
|
1332
|
+
sys.exit(1)
|
|
1333
|
+
|
|
1334
|
+
context_command(
|
|
1335
|
+
args.file,
|
|
1336
|
+
lines_range=getattr(args, "lines", None),
|
|
1337
|
+
full=getattr(args, "full", False),
|
|
1338
|
+
json_output=getattr(args, "json", False),
|
|
1339
|
+
query=getattr(args, "query", None),
|
|
1340
|
+
project_dir=project_dir,
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
# ===================================================================
|
|
1345
|
+
# rule
|
|
1346
|
+
# ===================================================================
|
|
1347
|
+
|
|
1348
|
+
def cmd_rule(args):
|
|
1349
|
+
"""Manage agent rules."""
|
|
1350
|
+
rule_action = getattr(args, "rule_action", None)
|
|
1351
|
+
|
|
1352
|
+
rule_tools_help = " | ".join(list(TOOL_CHOICES))
|
|
1353
|
+
if rule_action == "add":
|
|
1354
|
+
tool = getattr(args, "tool", None)
|
|
1355
|
+
if not tool:
|
|
1356
|
+
print(f"--tool is required. Use --tool <{rule_tools_help}>", file=sys.stderr)
|
|
1357
|
+
sys.exit(1)
|
|
1358
|
+
path = add_rule(args.rule_name, tool)
|
|
1359
|
+
print(f"Rule '{args.rule_name}' added for {tool}: {path}")
|
|
1360
|
+
|
|
1361
|
+
elif rule_action == "remove":
|
|
1362
|
+
tool = getattr(args, "tool", None)
|
|
1363
|
+
if not tool:
|
|
1364
|
+
print(f"--tool is required. Use --tool <{rule_tools_help}>", file=sys.stderr)
|
|
1365
|
+
sys.exit(1)
|
|
1366
|
+
path = remove_rule(args.rule_name, tool)
|
|
1367
|
+
if path:
|
|
1368
|
+
print(f"Rule '{args.rule_name}' removed for {tool}: {path}")
|
|
1369
|
+
else:
|
|
1370
|
+
print(f"Rule '{args.rule_name}' is not configured for {tool}.")
|
|
1371
|
+
|
|
1372
|
+
elif rule_action == "show":
|
|
1373
|
+
active = show_rules()
|
|
1374
|
+
if not active:
|
|
1375
|
+
print("No agent-trace rules are configured.")
|
|
1376
|
+
print("Use 'agent-trace rule list' to see available rules.")
|
|
1377
|
+
return
|
|
1378
|
+
print("Configured agent-trace rules:\n")
|
|
1379
|
+
for entry in active:
|
|
1380
|
+
print(f" {entry['name']:<25} tool: {entry['tool']:<10} {entry['path']}")
|
|
1381
|
+
print()
|
|
1382
|
+
|
|
1383
|
+
elif rule_action == "list":
|
|
1384
|
+
available = list_available_rules()
|
|
1385
|
+
if not available:
|
|
1386
|
+
print("No prebuilt rules available.")
|
|
1387
|
+
return
|
|
1388
|
+
print("Available agent-trace rules:\n")
|
|
1389
|
+
for entry in available:
|
|
1390
|
+
print(f" {entry['name']:<25} {entry['description']}")
|
|
1391
|
+
print()
|
|
1392
|
+
print(f"Add a rule with: agent-trace rule add <name> --tool <{'|'.join(list(TOOL_CHOICES))}>")
|
|
1393
|
+
|
|
1394
|
+
else:
|
|
1395
|
+
print("Usage: agent-trace rule {add,remove,show,list}")
|
|
1396
|
+
print("Run 'agent-trace rule --help' for details.")
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
# ===================================================================
|
|
1400
|
+
# set globaluser
|
|
1401
|
+
# ===================================================================
|
|
1402
|
+
|
|
1403
|
+
def cmd_set_globaluser(args):
|
|
1404
|
+
config = get_global_config()
|
|
1405
|
+
config["auth_token"] = args.token
|
|
1406
|
+
save_global_config(config)
|
|
1407
|
+
from .storage import get_global_config_file
|
|
1408
|
+
print(f"Global auth token saved to {get_global_config_file()}")
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
# ===================================================================
|
|
1412
|
+
# remove globaluser
|
|
1413
|
+
# ===================================================================
|
|
1414
|
+
|
|
1415
|
+
def cmd_remove_globaluser(_args):
|
|
1416
|
+
config = get_global_config()
|
|
1417
|
+
if "auth_token" in config:
|
|
1418
|
+
del config["auth_token"]
|
|
1419
|
+
save_global_config(config)
|
|
1420
|
+
print("Global auth token removed.")
|
|
1421
|
+
else:
|
|
1422
|
+
print("No global auth token is currently configured.")
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
# ===================================================================
|
|
1426
|
+
# projects / adopt (Phase 1b registry)
|
|
1427
|
+
# ===================================================================
|
|
1428
|
+
|
|
1429
|
+
def cmd_projects(args):
|
|
1430
|
+
"""List registered projects or show one."""
|
|
1431
|
+
import json
|
|
1432
|
+
|
|
1433
|
+
from .registry import get_project_record
|
|
1434
|
+
|
|
1435
|
+
rest = list(getattr(args, "projects_args", None) or [])
|
|
1436
|
+
if len(rest) >= 2 and rest[0] == "show":
|
|
1437
|
+
rec = get_project_record(rest[1])
|
|
1438
|
+
if not rec:
|
|
1439
|
+
print(f"agent-trace: unknown project id: {rest[1]}", file=sys.stderr)
|
|
1440
|
+
sys.exit(1)
|
|
1441
|
+
print(json.dumps({"project_id": rest[1], **rec}, indent=2))
|
|
1442
|
+
return
|
|
1443
|
+
if rest:
|
|
1444
|
+
print("Usage: agent-trace projects | agent-trace projects show <project_id>", file=sys.stderr)
|
|
1445
|
+
sys.exit(1)
|
|
1446
|
+
|
|
1447
|
+
rows = list_projects()
|
|
1448
|
+
if not rows:
|
|
1449
|
+
print("No registered projects.")
|
|
1450
|
+
return
|
|
1451
|
+
for row in rows:
|
|
1452
|
+
pid = row.get("project_id", "?")
|
|
1453
|
+
root = row.get("canonical_root", "")
|
|
1454
|
+
print(f" {pid} {root}")
|
|
1455
|
+
print()
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def cmd_adopt(args):
|
|
1459
|
+
"""Register a repository and print its stable project_id."""
|
|
1460
|
+
path = os.path.abspath(os.path.expanduser(getattr(args, "adopt_path", ".") or "."))
|
|
1461
|
+
gr = git_repo_root_for_path(path)
|
|
1462
|
+
if not gr:
|
|
1463
|
+
print("agent-trace adopt: not a git repository", file=sys.stderr)
|
|
1464
|
+
sys.exit(1)
|
|
1465
|
+
pid = lookup_or_create_project_id(gr)
|
|
1466
|
+
print(pid)
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
# ===================================================================
|
|
1470
|
+
# project create — register a project on a remote service
|
|
1471
|
+
# ===================================================================
|
|
1472
|
+
|
|
1473
|
+
def cmd_project(args):
|
|
1474
|
+
"""Server-side project administration (currently: create)."""
|
|
1475
|
+
action = getattr(args, "project_action", None)
|
|
1476
|
+
if action == "create":
|
|
1477
|
+
return _cmd_project_create(args)
|
|
1478
|
+
print(
|
|
1479
|
+
"Usage: agent-trace project create <remote-url> [--token TOKEN | --token-env VAR] "
|
|
1480
|
+
"[--name NAME] [--description TEXT]",
|
|
1481
|
+
file=sys.stderr,
|
|
1482
|
+
)
|
|
1483
|
+
sys.exit(2)
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def _cmd_project_create(args):
|
|
1487
|
+
url = args.url
|
|
1488
|
+
try:
|
|
1489
|
+
base_url, org_slug, project_slug = parse_remote_url(url)
|
|
1490
|
+
except RemoteUrlError as e:
|
|
1491
|
+
print(f"agent-trace project create: {e}", file=sys.stderr)
|
|
1492
|
+
sys.exit(1)
|
|
1493
|
+
|
|
1494
|
+
token_value = getattr(args, "token", None)
|
|
1495
|
+
token_env = getattr(args, "token_env", None)
|
|
1496
|
+
if token_value is None and token_env:
|
|
1497
|
+
token_value = os.environ.get(token_env)
|
|
1498
|
+
admin_secret = os.environ.get("AGENT_TRACE_ADMIN_SECRET")
|
|
1499
|
+
|
|
1500
|
+
if not token_value and not admin_secret:
|
|
1501
|
+
print(
|
|
1502
|
+
"agent-trace project create: a token is required. Pass --token / --token-env, "
|
|
1503
|
+
"or set AGENT_TRACE_ADMIN_SECRET to use the admin path.",
|
|
1504
|
+
file=sys.stderr,
|
|
1505
|
+
)
|
|
1506
|
+
sys.exit(1)
|
|
1507
|
+
|
|
1508
|
+
# Pre-flight scope check: refuse to create the project if the URL's
|
|
1509
|
+
# ``<org_slug>`` doesn't match the token's actual org. Without this,
|
|
1510
|
+
# the server would (silently) create the row under the token's org and
|
|
1511
|
+
# the local remote would record the wrong org slug — drift the user
|
|
1512
|
+
# only notices when traces vanish from "their" org.
|
|
1513
|
+
if token_value:
|
|
1514
|
+
try:
|
|
1515
|
+
assert_token_matches_url(
|
|
1516
|
+
base_url, token_value,
|
|
1517
|
+
expected_org_slug=org_slug,
|
|
1518
|
+
expected_project_slug=project_slug,
|
|
1519
|
+
)
|
|
1520
|
+
except TokenScopeError as e:
|
|
1521
|
+
print(f"agent-trace project create: {e}", file=sys.stderr)
|
|
1522
|
+
sys.exit(1)
|
|
1523
|
+
|
|
1524
|
+
try:
|
|
1525
|
+
proj = register_project_via_remote(
|
|
1526
|
+
base_url, project_slug,
|
|
1527
|
+
org_slug=org_slug,
|
|
1528
|
+
token=token_value, admin_secret=admin_secret,
|
|
1529
|
+
name=getattr(args, "name", None),
|
|
1530
|
+
description=getattr(args, "description", None),
|
|
1531
|
+
)
|
|
1532
|
+
except ProjectRegistrationError as e:
|
|
1533
|
+
print(f"agent-trace project create: {e}", file=sys.stderr)
|
|
1534
|
+
sys.exit(1 if e.status != 409 else 2)
|
|
1535
|
+
|
|
1536
|
+
print(json.dumps(proj, indent=2))
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
# ===================================================================
|
|
1540
|
+
# remote
|
|
1541
|
+
# ===================================================================
|
|
1542
|
+
|
|
1543
|
+
def _resolve_pid_for_remote():
|
|
1544
|
+
from .storage import resolve_project_id
|
|
1545
|
+
pid = resolve_project_id(os.getcwd(), create=False)
|
|
1546
|
+
if not pid:
|
|
1547
|
+
print("agent-trace: not in an initialised project. Run 'agent-trace init' first.", file=sys.stderr)
|
|
1548
|
+
sys.exit(1)
|
|
1549
|
+
return pid
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def cmd_remote(args):
|
|
1553
|
+
"""Manage named remotes (git remote-like)."""
|
|
1554
|
+
action = getattr(args, "remote_action", None)
|
|
1555
|
+
|
|
1556
|
+
if action == "add":
|
|
1557
|
+
pid = _resolve_pid_for_remote()
|
|
1558
|
+
url = args.url
|
|
1559
|
+
token_value = getattr(args, "token", None)
|
|
1560
|
+
token_env = getattr(args, "token_env", None)
|
|
1561
|
+
|
|
1562
|
+
# Parse + scope-check up front, regardless of --create. We refuse to
|
|
1563
|
+
# bind a remote whose URL points at one org while the token belongs
|
|
1564
|
+
# to another — local config and server state would silently diverge.
|
|
1565
|
+
try:
|
|
1566
|
+
base_url, url_org_slug, url_project_slug = parse_remote_url(url)
|
|
1567
|
+
except RemoteUrlError as e:
|
|
1568
|
+
print(f"agent-trace remote add: {e}", file=sys.stderr)
|
|
1569
|
+
sys.exit(1)
|
|
1570
|
+
|
|
1571
|
+
resolved_token = token_value
|
|
1572
|
+
if resolved_token is None and token_env:
|
|
1573
|
+
resolved_token = os.environ.get(token_env)
|
|
1574
|
+
|
|
1575
|
+
if resolved_token:
|
|
1576
|
+
try:
|
|
1577
|
+
assert_token_matches_url(
|
|
1578
|
+
base_url, resolved_token,
|
|
1579
|
+
expected_org_slug=url_org_slug,
|
|
1580
|
+
expected_project_slug=url_project_slug,
|
|
1581
|
+
)
|
|
1582
|
+
except TokenScopeError as e:
|
|
1583
|
+
print(f"agent-trace remote add: {e}", file=sys.stderr)
|
|
1584
|
+
sys.exit(1)
|
|
1585
|
+
|
|
1586
|
+
if getattr(args, "create", False):
|
|
1587
|
+
admin_secret = os.environ.get("AGENT_TRACE_ADMIN_SECRET")
|
|
1588
|
+
if not resolved_token and not admin_secret:
|
|
1589
|
+
print(
|
|
1590
|
+
"agent-trace remote add --create: a token is required to register the "
|
|
1591
|
+
"project. Pass --token, --token-env, or set AGENT_TRACE_ADMIN_SECRET.",
|
|
1592
|
+
file=sys.stderr,
|
|
1593
|
+
)
|
|
1594
|
+
sys.exit(1)
|
|
1595
|
+
try:
|
|
1596
|
+
proj = register_project_via_remote(
|
|
1597
|
+
base_url, url_project_slug,
|
|
1598
|
+
org_slug=url_org_slug,
|
|
1599
|
+
token=resolved_token, admin_secret=admin_secret,
|
|
1600
|
+
)
|
|
1601
|
+
print(f"Registered project '{proj['project_id']}' on {base_url}")
|
|
1602
|
+
except ProjectRegistrationError as e:
|
|
1603
|
+
if e.status == 409:
|
|
1604
|
+
print(f"Project '{url_project_slug}' already exists; binding the remote anyway.")
|
|
1605
|
+
else:
|
|
1606
|
+
print(f"agent-trace remote add --create: {e}", file=sys.stderr)
|
|
1607
|
+
sys.exit(1)
|
|
1608
|
+
|
|
1609
|
+
try:
|
|
1610
|
+
entry = remote_add(
|
|
1611
|
+
pid, args.name, url,
|
|
1612
|
+
token=token_value, token_env=token_env,
|
|
1613
|
+
)
|
|
1614
|
+
print(f"Remote '{args.name}' added: {entry['url']}")
|
|
1615
|
+
except ValueError as e:
|
|
1616
|
+
print(f"agent-trace remote add: {e}", file=sys.stderr)
|
|
1617
|
+
sys.exit(1)
|
|
1618
|
+
|
|
1619
|
+
elif action == "list":
|
|
1620
|
+
pid = _resolve_pid_for_remote()
|
|
1621
|
+
remotes = remote_list(pid)
|
|
1622
|
+
if not remotes:
|
|
1623
|
+
print("No remotes configured. Run 'agent-trace remote add <name> <url>'.")
|
|
1624
|
+
return
|
|
1625
|
+
for r in remotes:
|
|
1626
|
+
ref = r["token_ref"] or "(no auth)"
|
|
1627
|
+
print(f" {r['name']:<15} {r['url']} (token: {ref})")
|
|
1628
|
+
|
|
1629
|
+
elif action == "show":
|
|
1630
|
+
pid = _resolve_pid_for_remote()
|
|
1631
|
+
info = remote_show(pid, args.name)
|
|
1632
|
+
if not info:
|
|
1633
|
+
print(f"Remote '{args.name}' not found.", file=sys.stderr)
|
|
1634
|
+
sys.exit(1)
|
|
1635
|
+
print(f" Name: {info['name']}")
|
|
1636
|
+
print(f" URL: {info['url']}")
|
|
1637
|
+
print(f" Host: {info['base_url']}")
|
|
1638
|
+
print(f" Org: {info['org_slug']}")
|
|
1639
|
+
print(f" Project: {info['project_slug']}")
|
|
1640
|
+
print(f" Auth: {info['auth_type']}")
|
|
1641
|
+
print(f" Token: {info['token_masked']} (ref: {info['token_ref']})")
|
|
1642
|
+
|
|
1643
|
+
elif action == "set-url":
|
|
1644
|
+
pid = _resolve_pid_for_remote()
|
|
1645
|
+
# Validate the new URL's slugs against whatever token the remote
|
|
1646
|
+
# already holds. Same rationale as `remote add`: a URL change must
|
|
1647
|
+
# not leave the local remote pointing at one org while the token
|
|
1648
|
+
# belongs to another.
|
|
1649
|
+
try:
|
|
1650
|
+
base_url, url_org_slug, url_project_slug = parse_remote_url(args.url)
|
|
1651
|
+
except RemoteUrlError as e:
|
|
1652
|
+
print(f"agent-trace remote set-url: {e}", file=sys.stderr)
|
|
1653
|
+
sys.exit(1)
|
|
1654
|
+
from .remote import get_remote as _get_remote, get_remote_token as _get_token
|
|
1655
|
+
existing = _get_remote(pid, args.name)
|
|
1656
|
+
if existing is None:
|
|
1657
|
+
print(f"agent-trace remote set-url: Remote '{args.name}' does not exist.", file=sys.stderr)
|
|
1658
|
+
sys.exit(1)
|
|
1659
|
+
token_value = _get_token(existing)
|
|
1660
|
+
if token_value:
|
|
1661
|
+
try:
|
|
1662
|
+
assert_token_matches_url(
|
|
1663
|
+
base_url, token_value,
|
|
1664
|
+
expected_org_slug=url_org_slug,
|
|
1665
|
+
expected_project_slug=url_project_slug,
|
|
1666
|
+
)
|
|
1667
|
+
except TokenScopeError as e:
|
|
1668
|
+
print(f"agent-trace remote set-url: {e}", file=sys.stderr)
|
|
1669
|
+
sys.exit(1)
|
|
1670
|
+
try:
|
|
1671
|
+
set_remote_url(pid, args.name, args.url)
|
|
1672
|
+
print(f"Remote '{args.name}' URL updated.")
|
|
1673
|
+
except ValueError as e:
|
|
1674
|
+
print(f"agent-trace remote set-url: {e}", file=sys.stderr)
|
|
1675
|
+
sys.exit(1)
|
|
1676
|
+
|
|
1677
|
+
elif action == "set-token":
|
|
1678
|
+
pid = _resolve_pid_for_remote()
|
|
1679
|
+
# Validate the new token against the remote's current URL before we
|
|
1680
|
+
# persist it, so the user catches an org/project mismatch up front
|
|
1681
|
+
# rather than at the next sync.
|
|
1682
|
+
from .remote import (
|
|
1683
|
+
get_remote as _get_remote,
|
|
1684
|
+
get_remote_base_url as _get_base,
|
|
1685
|
+
get_remote_org_slug as _get_org,
|
|
1686
|
+
get_remote_project_slug as _get_proj,
|
|
1687
|
+
)
|
|
1688
|
+
existing = _get_remote(pid, args.name)
|
|
1689
|
+
if existing is None:
|
|
1690
|
+
print(f"agent-trace remote set-token: Remote '{args.name}' does not exist.", file=sys.stderr)
|
|
1691
|
+
sys.exit(1)
|
|
1692
|
+
new_token = getattr(args, "token", None)
|
|
1693
|
+
new_token_env = getattr(args, "token_env", None)
|
|
1694
|
+
resolved_new_token = new_token
|
|
1695
|
+
if resolved_new_token is None and new_token_env:
|
|
1696
|
+
resolved_new_token = os.environ.get(new_token_env)
|
|
1697
|
+
base_url = _get_base(existing)
|
|
1698
|
+
url_org_slug = _get_org(existing)
|
|
1699
|
+
url_project_slug = _get_proj(existing)
|
|
1700
|
+
if resolved_new_token and base_url and url_org_slug:
|
|
1701
|
+
try:
|
|
1702
|
+
assert_token_matches_url(
|
|
1703
|
+
base_url, resolved_new_token,
|
|
1704
|
+
expected_org_slug=url_org_slug,
|
|
1705
|
+
expected_project_slug=url_project_slug,
|
|
1706
|
+
)
|
|
1707
|
+
except TokenScopeError as e:
|
|
1708
|
+
print(f"agent-trace remote set-token: {e}", file=sys.stderr)
|
|
1709
|
+
sys.exit(1)
|
|
1710
|
+
try:
|
|
1711
|
+
set_remote_token(
|
|
1712
|
+
pid, args.name,
|
|
1713
|
+
token=new_token,
|
|
1714
|
+
token_env=new_token_env,
|
|
1715
|
+
)
|
|
1716
|
+
print(f"Remote '{args.name}' token updated.")
|
|
1717
|
+
except ValueError as e:
|
|
1718
|
+
print(f"agent-trace remote set-token: {e}", file=sys.stderr)
|
|
1719
|
+
sys.exit(1)
|
|
1720
|
+
|
|
1721
|
+
elif action == "remove":
|
|
1722
|
+
pid = _resolve_pid_for_remote()
|
|
1723
|
+
if remote_remove(pid, args.name):
|
|
1724
|
+
print(f"Remote '{args.name}' removed.")
|
|
1725
|
+
else:
|
|
1726
|
+
print(f"Remote '{args.name}' not found.", file=sys.stderr)
|
|
1727
|
+
sys.exit(1)
|
|
1728
|
+
|
|
1729
|
+
elif action == "rename":
|
|
1730
|
+
pid = _resolve_pid_for_remote()
|
|
1731
|
+
try:
|
|
1732
|
+
remote_rename(pid, args.old_name, args.new_name)
|
|
1733
|
+
print(f"Remote '{args.old_name}' renamed to '{args.new_name}'.")
|
|
1734
|
+
except ValueError as e:
|
|
1735
|
+
print(f"agent-trace remote rename: {e}", file=sys.stderr)
|
|
1736
|
+
sys.exit(1)
|
|
1737
|
+
|
|
1738
|
+
elif action == "default":
|
|
1739
|
+
pid = _resolve_pid_for_remote()
|
|
1740
|
+
try:
|
|
1741
|
+
set_default_remote(pid, args.name)
|
|
1742
|
+
print(f"Default remote set to '{args.name}'.")
|
|
1743
|
+
except ValueError as e:
|
|
1744
|
+
print(f"agent-trace remote default: {e}", file=sys.stderr)
|
|
1745
|
+
sys.exit(1)
|
|
1746
|
+
else:
|
|
1747
|
+
print("Usage: agent-trace remote {add,list,show,set-url,set-token,remove,rename,default}")
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
# ===================================================================
|
|
1751
|
+
# push / pull / sync
|
|
1752
|
+
# ===================================================================
|
|
1753
|
+
|
|
1754
|
+
def cmd_push(args):
|
|
1755
|
+
"""Push local data to a remote service."""
|
|
1756
|
+
pid = _resolve_pid_for_remote()
|
|
1757
|
+
try:
|
|
1758
|
+
result = sync_push(
|
|
1759
|
+
pid,
|
|
1760
|
+
remote_name=getattr(args, "remote", None),
|
|
1761
|
+
full=getattr(args, "full", False),
|
|
1762
|
+
only=getattr(args, "only", None),
|
|
1763
|
+
since=getattr(args, "since", None),
|
|
1764
|
+
dry_run=getattr(args, "dry_run", False),
|
|
1765
|
+
)
|
|
1766
|
+
except ValueError as e:
|
|
1767
|
+
print(f"agent-trace push: {e}", file=sys.stderr)
|
|
1768
|
+
sys.exit(1)
|
|
1769
|
+
|
|
1770
|
+
prefix = "[dry-run] " if result.dry_run else ""
|
|
1771
|
+
print(f"{prefix}Pushed {result.traces_pushed} trace(s), "
|
|
1772
|
+
f"{result.ledgers_pushed} ledger(s), "
|
|
1773
|
+
f"{result.commit_links_pushed} commit-link(s), "
|
|
1774
|
+
f"{result.conversations_pushed} conversation(s), "
|
|
1775
|
+
f"{result.summaries_pushed} summary(ies)")
|
|
1776
|
+
if result.traces_held_back:
|
|
1777
|
+
print(f" ({result.traces_held_back} unattributed trace(s) held back; "
|
|
1778
|
+
"use --full to push)")
|
|
1779
|
+
for err in result.errors:
|
|
1780
|
+
print(f" Error: {err}", file=sys.stderr)
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def cmd_pull(args):
|
|
1784
|
+
"""Pull remote data into local storage."""
|
|
1785
|
+
pid = _resolve_pid_for_remote()
|
|
1786
|
+
try:
|
|
1787
|
+
result = sync_pull(
|
|
1788
|
+
pid,
|
|
1789
|
+
remote_name=getattr(args, "remote", None),
|
|
1790
|
+
since=getattr(args, "since", None),
|
|
1791
|
+
dry_run=getattr(args, "dry_run", False),
|
|
1792
|
+
)
|
|
1793
|
+
except ValueError as e:
|
|
1794
|
+
print(f"agent-trace pull: {e}", file=sys.stderr)
|
|
1795
|
+
sys.exit(1)
|
|
1796
|
+
|
|
1797
|
+
prefix = "[dry-run] " if result.dry_run else ""
|
|
1798
|
+
print(f"{prefix}Pulled {result.traces_pulled} trace(s), "
|
|
1799
|
+
f"{result.ledgers_pulled} ledger(s), "
|
|
1800
|
+
f"{result.commit_links_pulled} commit-link(s), "
|
|
1801
|
+
f"{result.conversations_pulled} conversation(s), "
|
|
1802
|
+
f"{result.summaries_pulled} summary(ies)")
|
|
1803
|
+
for err in result.errors:
|
|
1804
|
+
print(f" Error: {err}", file=sys.stderr)
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
def cmd_sync(args):
|
|
1808
|
+
"""Push + pull in one go."""
|
|
1809
|
+
pid = _resolve_pid_for_remote()
|
|
1810
|
+
rn = getattr(args, "remote", None)
|
|
1811
|
+
try:
|
|
1812
|
+
push_r = sync_push(pid, remote_name=rn)
|
|
1813
|
+
except ValueError as e:
|
|
1814
|
+
print(f"agent-trace sync (push): {e}", file=sys.stderr)
|
|
1815
|
+
push_r = None
|
|
1816
|
+
try:
|
|
1817
|
+
pull_r = sync_pull(pid, remote_name=rn)
|
|
1818
|
+
except ValueError as e:
|
|
1819
|
+
print(f"agent-trace sync (pull): {e}", file=sys.stderr)
|
|
1820
|
+
pull_r = None
|
|
1821
|
+
|
|
1822
|
+
if push_r:
|
|
1823
|
+
print(f"Pushed {push_r.traces_pushed} trace(s), "
|
|
1824
|
+
f"{push_r.ledgers_pushed} ledger(s), "
|
|
1825
|
+
f"{push_r.commit_links_pushed} commit-link(s), "
|
|
1826
|
+
f"{push_r.conversations_pushed} conversation(s), "
|
|
1827
|
+
f"{push_r.summaries_pushed} summary(ies)")
|
|
1828
|
+
if pull_r:
|
|
1829
|
+
print(f"Pulled {pull_r.traces_pulled} trace(s), "
|
|
1830
|
+
f"{pull_r.ledgers_pulled} ledger(s), "
|
|
1831
|
+
f"{pull_r.commit_links_pulled} commit-link(s), "
|
|
1832
|
+
f"{pull_r.conversations_pulled} conversation(s), "
|
|
1833
|
+
f"{pull_r.summaries_pulled} summary(ies)")
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
# ===================================================================
|
|
1837
|
+
# notes (git refs/notes/agent-trace)
|
|
1838
|
+
# ===================================================================
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _resolve_note_section_flags(
|
|
1842
|
+
args,
|
|
1843
|
+
cwd: str,
|
|
1844
|
+
) -> tuple[bool, bool, bool, bool]:
|
|
1845
|
+
"""Merge ``--include-*`` / ``--no-include-*`` with project ``notes.*`` defaults.
|
|
1846
|
+
|
|
1847
|
+
Returns ``(include_ledger, include_summary, include_prompts,
|
|
1848
|
+
include_all_session_conversations)``.
|
|
1849
|
+
"""
|
|
1850
|
+
from .git_notes import all_session_conversations_enabled, project_notes_flags
|
|
1851
|
+
|
|
1852
|
+
d_l, d_s, d_p = project_notes_flags(cwd)
|
|
1853
|
+
d_asc = all_session_conversations_enabled(cwd)
|
|
1854
|
+
|
|
1855
|
+
def one(name: str, default: bool) -> bool:
|
|
1856
|
+
t = getattr(args, f"include_{name}", False)
|
|
1857
|
+
f = getattr(args, f"no_include_{name}", False)
|
|
1858
|
+
if t and f:
|
|
1859
|
+
print(
|
|
1860
|
+
f"agent-trace notes: specify only one of --include-{name} or --no-include-{name}",
|
|
1861
|
+
file=sys.stderr,
|
|
1862
|
+
)
|
|
1863
|
+
sys.exit(1)
|
|
1864
|
+
if t:
|
|
1865
|
+
return True
|
|
1866
|
+
if f:
|
|
1867
|
+
return False
|
|
1868
|
+
return default
|
|
1869
|
+
|
|
1870
|
+
return (
|
|
1871
|
+
one("ledger", d_l),
|
|
1872
|
+
one("summary", d_s),
|
|
1873
|
+
one("prompts", d_p),
|
|
1874
|
+
one("all_session_conversations", d_asc),
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
def cmd_summary(args):
|
|
1879
|
+
"""Enable/disable/configure pluggable session summaries."""
|
|
1880
|
+
from .storage import resolve_project_id
|
|
1881
|
+
from .summary import get_summary_for_commit, run_summary_generate
|
|
1882
|
+
from .git_notes import resolve_commit
|
|
1883
|
+
|
|
1884
|
+
cwd = os.getcwd()
|
|
1885
|
+
action = getattr(args, "summary_action", None)
|
|
1886
|
+
|
|
1887
|
+
if action == "enable":
|
|
1888
|
+
cfg = get_project_config(cwd)
|
|
1889
|
+
if cfg is None:
|
|
1890
|
+
print("agent-trace: project not initialised (run agent-trace init)", file=sys.stderr)
|
|
1891
|
+
sys.exit(1)
|
|
1892
|
+
# Use ``summary_command`` dest so we do not overwrite the top-level ``command`` (subcommand name).
|
|
1893
|
+
cmd = getattr(args, "summary_command", None) or ""
|
|
1894
|
+
if not cmd.strip():
|
|
1895
|
+
print("agent-trace summary enable: --command is required", file=sys.stderr)
|
|
1896
|
+
sys.exit(1)
|
|
1897
|
+
cfg.setdefault("summary", {})
|
|
1898
|
+
cfg["summary"]["enabled"] = True
|
|
1899
|
+
cfg["summary"]["command"] = cmd
|
|
1900
|
+
to = getattr(args, "summary_timeout", None)
|
|
1901
|
+
if to is not None:
|
|
1902
|
+
cfg["summary"]["timeout_seconds"] = int(to)
|
|
1903
|
+
save_project_config(cfg, cwd)
|
|
1904
|
+
print("Session summaries enabled.")
|
|
1905
|
+
return
|
|
1906
|
+
|
|
1907
|
+
if action == "presets":
|
|
1908
|
+
rows = list_summary_presets()
|
|
1909
|
+
print("Built-in summary presets:\n")
|
|
1910
|
+
for row in rows:
|
|
1911
|
+
alias = row["alias"]
|
|
1912
|
+
desc = row["description"]
|
|
1913
|
+
if row.get("needs_model"):
|
|
1914
|
+
dm = row.get("default_model") or DEFAULT_OLLAMA_MODEL
|
|
1915
|
+
print(f" {alias:<16} {desc} (model required; default: {dm})")
|
|
1916
|
+
else:
|
|
1917
|
+
print(f" {alias:<16} {desc}")
|
|
1918
|
+
print()
|
|
1919
|
+
print("Configure one with:")
|
|
1920
|
+
print(" agent-trace summary use <preset> [--model <name>] [--timeout <seconds>]")
|
|
1921
|
+
return
|
|
1922
|
+
|
|
1923
|
+
if action == "use":
|
|
1924
|
+
cfg = get_project_config(cwd)
|
|
1925
|
+
if cfg is None:
|
|
1926
|
+
print("agent-trace: project not initialised (run agent-trace init)", file=sys.stderr)
|
|
1927
|
+
sys.exit(1)
|
|
1928
|
+
alias = (getattr(args, "preset_alias", None) or "").strip()
|
|
1929
|
+
if alias not in PRESET_ALIASES:
|
|
1930
|
+
print(
|
|
1931
|
+
f"agent-trace summary use: unknown preset '{alias}'. "
|
|
1932
|
+
"Run 'agent-trace summary presets'.",
|
|
1933
|
+
file=sys.stderr,
|
|
1934
|
+
)
|
|
1935
|
+
sys.exit(1)
|
|
1936
|
+
model = getattr(args, "model", None)
|
|
1937
|
+
if alias != "ollama-summary" and model:
|
|
1938
|
+
print(
|
|
1939
|
+
"agent-trace summary use: --model is only valid with ollama-summary",
|
|
1940
|
+
file=sys.stderr,
|
|
1941
|
+
)
|
|
1942
|
+
sys.exit(1)
|
|
1943
|
+
command = build_preset_command(alias, model=model)
|
|
1944
|
+
cfg.setdefault("summary", {})
|
|
1945
|
+
cfg["summary"]["enabled"] = True
|
|
1946
|
+
cfg["summary"]["command"] = command
|
|
1947
|
+
to = getattr(args, "summary_timeout", None)
|
|
1948
|
+
if to is not None:
|
|
1949
|
+
cfg["summary"]["timeout_seconds"] = int(to)
|
|
1950
|
+
save_project_config(cfg, cwd)
|
|
1951
|
+
print(f"Session summaries enabled using preset '{alias}'.")
|
|
1952
|
+
print(f"summary.command = {command}")
|
|
1953
|
+
return
|
|
1954
|
+
|
|
1955
|
+
if action == "preset-run":
|
|
1956
|
+
alias = (getattr(args, "preset_alias", None) or "").strip()
|
|
1957
|
+
model = getattr(args, "model", None)
|
|
1958
|
+
transcript_text = sys.stdin.read()
|
|
1959
|
+
code = run_summary_preset(alias, transcript_text, model=model)
|
|
1960
|
+
if code != 0:
|
|
1961
|
+
print(
|
|
1962
|
+
"agent-trace summary preset-run: failed "
|
|
1963
|
+
f"(preset={alias}, ensure required tool is installed and authenticated)",
|
|
1964
|
+
file=sys.stderr,
|
|
1965
|
+
)
|
|
1966
|
+
sys.exit(code)
|
|
1967
|
+
return
|
|
1968
|
+
|
|
1969
|
+
if action == "disable":
|
|
1970
|
+
cfg = get_project_config(cwd)
|
|
1971
|
+
if cfg is None:
|
|
1972
|
+
print("agent-trace: project not initialised", file=sys.stderr)
|
|
1973
|
+
sys.exit(1)
|
|
1974
|
+
cfg.setdefault("summary", {})
|
|
1975
|
+
cfg["summary"]["enabled"] = False
|
|
1976
|
+
save_project_config(cfg, cwd)
|
|
1977
|
+
print("Session summaries disabled.")
|
|
1978
|
+
return
|
|
1979
|
+
|
|
1980
|
+
if action == "generate":
|
|
1981
|
+
cid = (getattr(args, "conversation_id", None) or "").strip()
|
|
1982
|
+
sid = (getattr(args, "session_id", None) or "").strip()
|
|
1983
|
+
if not (cid or sid):
|
|
1984
|
+
print(
|
|
1985
|
+
"agent-trace summary generate: --conversation ID or --session-id SID required",
|
|
1986
|
+
file=sys.stderr,
|
|
1987
|
+
)
|
|
1988
|
+
sys.exit(1)
|
|
1989
|
+
out = run_summary_generate(
|
|
1990
|
+
cwd,
|
|
1991
|
+
conversation_id=cid or None,
|
|
1992
|
+
session_id=sid or None,
|
|
1993
|
+
)
|
|
1994
|
+
if out is None:
|
|
1995
|
+
print("agent-trace summary generate: failed or no transcript", file=sys.stderr)
|
|
1996
|
+
sys.exit(1)
|
|
1997
|
+
print(json.dumps(out, indent=2))
|
|
1998
|
+
return
|
|
1999
|
+
|
|
2000
|
+
if action == "show":
|
|
2001
|
+
rev = getattr(args, "commit", None) or "HEAD"
|
|
2002
|
+
sha = resolve_commit(cwd, rev)
|
|
2003
|
+
if not sha:
|
|
2004
|
+
print(f"agent-trace summary show: bad revision: {rev}", file=sys.stderr)
|
|
2005
|
+
sys.exit(1)
|
|
2006
|
+
pid = resolve_project_id(cwd, create=False)
|
|
2007
|
+
if not pid:
|
|
2008
|
+
print("agent-trace summary show: no project id", file=sys.stderr)
|
|
2009
|
+
sys.exit(1)
|
|
2010
|
+
s = get_summary_for_commit(pid, sha)
|
|
2011
|
+
if not s:
|
|
2012
|
+
print(f"No summaries stored for {sha}", file=sys.stderr)
|
|
2013
|
+
sys.exit(1)
|
|
2014
|
+
print(json.dumps(s, indent=2))
|
|
2015
|
+
return
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def cmd_notes(args):
|
|
2019
|
+
"""Manage per-commit JSON notes on ``refs/notes/agent-trace``."""
|
|
2020
|
+
from .git_notes import (
|
|
2021
|
+
attach_note,
|
|
2022
|
+
backfill_notes,
|
|
2023
|
+
build_note,
|
|
2024
|
+
git_notes_pull,
|
|
2025
|
+
git_notes_push,
|
|
2026
|
+
read_note,
|
|
2027
|
+
rebuild_notes_for_range,
|
|
2028
|
+
resolve_commit,
|
|
2029
|
+
strip_sections,
|
|
2030
|
+
)
|
|
2031
|
+
from .ledger import load_local_ledgers
|
|
2032
|
+
|
|
2033
|
+
cwd = os.getcwd()
|
|
2034
|
+
action = getattr(args, "notes_action", None)
|
|
2035
|
+
|
|
2036
|
+
if action == "show":
|
|
2037
|
+
rev = getattr(args, "commit", None) or "HEAD"
|
|
2038
|
+
sha = resolve_commit(cwd, rev)
|
|
2039
|
+
if not sha:
|
|
2040
|
+
print(f"agent-trace notes show: bad revision: {rev}", file=sys.stderr)
|
|
2041
|
+
sys.exit(1)
|
|
2042
|
+
note = read_note(sha, cwd)
|
|
2043
|
+
if not note:
|
|
2044
|
+
print(f"No agent-trace note for {sha}", file=sys.stderr)
|
|
2045
|
+
sys.exit(1)
|
|
2046
|
+
print(json.dumps(note, indent=2))
|
|
2047
|
+
return
|
|
2048
|
+
|
|
2049
|
+
if action == "attach":
|
|
2050
|
+
rev = getattr(args, "commit", None) or "HEAD"
|
|
2051
|
+
sha = resolve_commit(cwd, rev)
|
|
2052
|
+
if not sha:
|
|
2053
|
+
print(f"agent-trace notes attach: bad revision: {rev}", file=sys.stderr)
|
|
2054
|
+
sys.exit(1)
|
|
2055
|
+
ledgers = load_local_ledgers(cwd)
|
|
2056
|
+
led = ledgers.get(sha)
|
|
2057
|
+
if not led:
|
|
2058
|
+
print(
|
|
2059
|
+
"agent-trace notes attach: no local ledger for this commit "
|
|
2060
|
+
"(build a ledger first, e.g. make a commit with agent-trace hooks).",
|
|
2061
|
+
file=sys.stderr,
|
|
2062
|
+
)
|
|
2063
|
+
sys.exit(1)
|
|
2064
|
+
il, isum, ipr, iasc = _resolve_note_section_flags(args, cwd)
|
|
2065
|
+
from .git_notes import load_traces_for_ids
|
|
2066
|
+
|
|
2067
|
+
tid_list = [str(x) for x in led.get("trace_ids", [])]
|
|
2068
|
+
traces = load_traces_for_ids(cwd, tid_list)
|
|
2069
|
+
summaries = None
|
|
2070
|
+
if isum:
|
|
2071
|
+
from .summary import merge_note_summaries
|
|
2072
|
+
|
|
2073
|
+
cfg = get_project_config(cwd) or {}
|
|
2074
|
+
nc = cfg.get("notes") or {}
|
|
2075
|
+
static_s = nc.get("summaries") if isinstance(nc.get("summaries"), dict) else None
|
|
2076
|
+
summaries = merge_note_summaries(cwd, led, static_s)
|
|
2077
|
+
asc = None
|
|
2078
|
+
if iasc:
|
|
2079
|
+
from .summary import all_session_conversations_for_ledger
|
|
2080
|
+
|
|
2081
|
+
asc = all_session_conversations_for_ledger(cwd, led)
|
|
2082
|
+
note = build_note(
|
|
2083
|
+
led,
|
|
2084
|
+
traces,
|
|
2085
|
+
include_ledger=il,
|
|
2086
|
+
include_summary=isum,
|
|
2087
|
+
include_prompts=ipr,
|
|
2088
|
+
summaries=summaries,
|
|
2089
|
+
include_all_session_conversations=iasc,
|
|
2090
|
+
all_session_conversations=asc,
|
|
2091
|
+
)
|
|
2092
|
+
if attach_note(sha, note, cwd):
|
|
2093
|
+
print(f"Attached note to {sha}")
|
|
2094
|
+
else:
|
|
2095
|
+
print("agent-trace notes attach: failed", file=sys.stderr)
|
|
2096
|
+
sys.exit(1)
|
|
2097
|
+
return
|
|
2098
|
+
|
|
2099
|
+
if action == "rebuild":
|
|
2100
|
+
range_spec = getattr(args, "range_spec", None)
|
|
2101
|
+
if not range_spec:
|
|
2102
|
+
print("agent-trace notes rebuild: <range> required (e.g. HEAD~10..HEAD)", file=sys.stderr)
|
|
2103
|
+
sys.exit(1)
|
|
2104
|
+
il, isum, ipr, iasc = _resolve_note_section_flags(args, cwd)
|
|
2105
|
+
n = rebuild_notes_for_range(
|
|
2106
|
+
cwd,
|
|
2107
|
+
range_spec,
|
|
2108
|
+
include_ledger=il,
|
|
2109
|
+
include_summary=isum,
|
|
2110
|
+
include_prompts=ipr,
|
|
2111
|
+
include_all_session_conversations=iasc,
|
|
2112
|
+
)
|
|
2113
|
+
print(f"Rebuilt notes for {n} commit(s)")
|
|
2114
|
+
return
|
|
2115
|
+
|
|
2116
|
+
if action == "backfill":
|
|
2117
|
+
il, isum, ipr, iasc = _resolve_note_section_flags(args, cwd)
|
|
2118
|
+
since = getattr(args, "since", None)
|
|
2119
|
+
n = backfill_notes(
|
|
2120
|
+
cwd,
|
|
2121
|
+
since=since,
|
|
2122
|
+
include_ledger=il,
|
|
2123
|
+
include_summary=isum,
|
|
2124
|
+
include_prompts=ipr,
|
|
2125
|
+
include_all_session_conversations=iasc,
|
|
2126
|
+
)
|
|
2127
|
+
print(f"Backfilled notes for {n} commit(s)")
|
|
2128
|
+
return
|
|
2129
|
+
|
|
2130
|
+
if action == "strip":
|
|
2131
|
+
rev = getattr(args, "commit", None) or "HEAD"
|
|
2132
|
+
sha = resolve_commit(cwd, rev)
|
|
2133
|
+
if not sha:
|
|
2134
|
+
print(f"agent-trace notes strip: bad revision: {rev}", file=sys.stderr)
|
|
2135
|
+
sys.exit(1)
|
|
2136
|
+
sections: list[str] = []
|
|
2137
|
+
if getattr(args, "strip_ledger", False):
|
|
2138
|
+
sections.append("ledger")
|
|
2139
|
+
if getattr(args, "strip_summary", False):
|
|
2140
|
+
sections.append("summary")
|
|
2141
|
+
if getattr(args, "strip_prompts", False):
|
|
2142
|
+
sections.append("prompts")
|
|
2143
|
+
if getattr(args, "strip_all_session_conversations", False):
|
|
2144
|
+
sections.append("all_session_conversations")
|
|
2145
|
+
if not sections:
|
|
2146
|
+
print(
|
|
2147
|
+
"agent-trace notes strip: specify at least one of "
|
|
2148
|
+
"--ledger --summary --prompts --all-session-conversations",
|
|
2149
|
+
file=sys.stderr,
|
|
2150
|
+
)
|
|
2151
|
+
sys.exit(1)
|
|
2152
|
+
if strip_sections(sha, sections, cwd):
|
|
2153
|
+
print(f"Stripped {', '.join(sections)} from note on {sha}")
|
|
2154
|
+
else:
|
|
2155
|
+
print("agent-trace notes strip: no note or failed", file=sys.stderr)
|
|
2156
|
+
sys.exit(1)
|
|
2157
|
+
return
|
|
2158
|
+
|
|
2159
|
+
if action == "push":
|
|
2160
|
+
remote = getattr(args, "remote", None) or "origin"
|
|
2161
|
+
ok, msg = git_notes_push(cwd, remote=remote)
|
|
2162
|
+
if ok:
|
|
2163
|
+
print(msg or "push ok")
|
|
2164
|
+
else:
|
|
2165
|
+
print(f"agent-trace notes push: {msg}", file=sys.stderr)
|
|
2166
|
+
sys.exit(1)
|
|
2167
|
+
return
|
|
2168
|
+
|
|
2169
|
+
if action == "pull":
|
|
2170
|
+
remote = getattr(args, "remote", None) or "origin"
|
|
2171
|
+
ok, msg = git_notes_pull(cwd, remote=remote)
|
|
2172
|
+
if ok:
|
|
2173
|
+
print(msg or "fetch ok")
|
|
2174
|
+
else:
|
|
2175
|
+
print(f"agent-trace notes pull: {msg}", file=sys.stderr)
|
|
2176
|
+
sys.exit(1)
|
|
2177
|
+
return
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
# ===================================================================
|
|
2181
|
+
# telemetry (global flag)
|
|
2182
|
+
# ===================================================================
|
|
2183
|
+
|
|
2184
|
+
|
|
2185
|
+
def cmd_telemetry_control(args) -> None:
|
|
2186
|
+
"""Handle ``agent-trace --telemetry on|off|status`` (no subcommand)."""
|
|
2187
|
+
mode = args.telemetry
|
|
2188
|
+
if mode == "on":
|
|
2189
|
+
set_telemetry_enabled(True)
|
|
2190
|
+
print("Anonymous telemetry is now enabled (see docs/concepts/telemetry.md).")
|
|
2191
|
+
return
|
|
2192
|
+
if mode == "off":
|
|
2193
|
+
set_telemetry_enabled(False)
|
|
2194
|
+
print("Telemetry is now disabled.")
|
|
2195
|
+
return
|
|
2196
|
+
for line in telemetry_status_lines():
|
|
2197
|
+
print(line)
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
# ===================================================================
|
|
2201
|
+
# Entry point
|
|
2202
|
+
# ===================================================================
|
|
2203
|
+
|
|
2204
|
+
def _run_subcommand(args, set_p, rm_p) -> None:
|
|
2205
|
+
"""Run one subcommand; may raise ``SystemExit``."""
|
|
2206
|
+
dispatch = {
|
|
2207
|
+
"init": cmd_init,
|
|
2208
|
+
"doctor": cmd_doctor,
|
|
2209
|
+
"status": cmd_status,
|
|
2210
|
+
"reset": cmd_reset,
|
|
2211
|
+
"config": cmd_config,
|
|
2212
|
+
"hooks": cmd_hooks,
|
|
2213
|
+
"record": cmd_record,
|
|
2214
|
+
"commit-link": cmd_commit_link,
|
|
2215
|
+
"rewrite-ledger": cmd_rewrite_ledger,
|
|
2216
|
+
"viewer": cmd_viewer,
|
|
2217
|
+
"blame": cmd_blame,
|
|
2218
|
+
"context": cmd_context,
|
|
2219
|
+
"projects": cmd_projects,
|
|
2220
|
+
"adopt": cmd_adopt,
|
|
2221
|
+
"project": cmd_project,
|
|
2222
|
+
"push": cmd_push,
|
|
2223
|
+
"pull": cmd_pull,
|
|
2224
|
+
"sync": cmd_sync,
|
|
2225
|
+
"notes": cmd_notes,
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
if args.command in dispatch:
|
|
2229
|
+
dispatch[args.command](args)
|
|
2230
|
+
elif args.command == "remote":
|
|
2231
|
+
cmd_remote(args)
|
|
2232
|
+
elif args.command == "rule":
|
|
2233
|
+
cmd_rule(args)
|
|
2234
|
+
elif args.command == "set":
|
|
2235
|
+
if getattr(args, "set_command", None) == "globaluser":
|
|
2236
|
+
cmd_set_globaluser(args)
|
|
2237
|
+
else:
|
|
2238
|
+
set_p.print_help()
|
|
2239
|
+
elif args.command == "remove":
|
|
2240
|
+
if getattr(args, "remove_command", None) == "globaluser":
|
|
2241
|
+
cmd_remove_globaluser(args)
|
|
2242
|
+
else:
|
|
2243
|
+
rm_p.print_help()
|
|
2244
|
+
elif args.command == "summary":
|
|
2245
|
+
cmd_summary(args)
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
def main():
|
|
2249
|
+
parser = argparse.ArgumentParser(
|
|
2250
|
+
prog="agent-trace",
|
|
2251
|
+
description="agent-trace — AI code tracing tool",
|
|
2252
|
+
)
|
|
2253
|
+
parser.add_argument(
|
|
2254
|
+
"--version", action="version", version=f"agent-trace {VERSION}",
|
|
2255
|
+
)
|
|
2256
|
+
parser.add_argument(
|
|
2257
|
+
"--telemetry",
|
|
2258
|
+
choices=["on", "off", "status"],
|
|
2259
|
+
default=None,
|
|
2260
|
+
metavar="MODE",
|
|
2261
|
+
help="opt-in anonymous telemetry: on, off, or status (no subcommand; default off)",
|
|
2262
|
+
)
|
|
2263
|
+
|
|
2264
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
2265
|
+
|
|
2266
|
+
sub.add_parser("init", help="Initialize agent-trace for the current project")
|
|
2267
|
+
doc_p = sub.add_parser("doctor", help="Check hooks, config, remotes, and optional tools")
|
|
2268
|
+
doc_p.add_argument(
|
|
2269
|
+
"--fix",
|
|
2270
|
+
action="store_true",
|
|
2271
|
+
help="Apply safe automatic fixes (permissions, init, hooks, git notes refspec)",
|
|
2272
|
+
)
|
|
2273
|
+
doc_p.add_argument(
|
|
2274
|
+
"--dry-run",
|
|
2275
|
+
dest="dry_run",
|
|
2276
|
+
action="store_true",
|
|
2277
|
+
help="With --fix, show what would change without modifying the system",
|
|
2278
|
+
)
|
|
2279
|
+
doc_p.add_argument(
|
|
2280
|
+
"--yes",
|
|
2281
|
+
"-y",
|
|
2282
|
+
action="store_true",
|
|
2283
|
+
help="With --fix, apply fixes non-interactively (e.g. run init without prompting)",
|
|
2284
|
+
)
|
|
2285
|
+
sub.add_parser("status", help="Show agent-trace status")
|
|
2286
|
+
sub.add_parser("reset", help="Reset agent-trace configuration")
|
|
2287
|
+
sub_config = sub.add_parser("config", help="Show or update persisted configuration")
|
|
2288
|
+
config_sub = sub_config.add_subparsers(dest="config_action", metavar="ACTION", required=True)
|
|
2289
|
+
c_show = config_sub.add_parser("show", help="Show full persisted configuration")
|
|
2290
|
+
c_show.add_argument("--json", action="store_true", default=False, help="Output as JSON")
|
|
2291
|
+
c_set = config_sub.add_parser("set", help="Set one configuration field")
|
|
2292
|
+
c_set.add_argument("field", choices=sorted(_CONFIG_FIELDS), help="Config field to update")
|
|
2293
|
+
c_set.add_argument("value", help="New value")
|
|
2294
|
+
c_reset = config_sub.add_parser("reset", help="Reset one configuration field or group")
|
|
2295
|
+
c_reset.add_argument("field", choices=sorted(_CONFIG_RESET_TARGETS), help="Config field/group to reset")
|
|
2296
|
+
c_reset.add_argument(
|
|
2297
|
+
"--yes",
|
|
2298
|
+
action="store_true",
|
|
2299
|
+
default=False,
|
|
2300
|
+
help="Reset directly without interactive prompt",
|
|
2301
|
+
)
|
|
2302
|
+
|
|
2303
|
+
# hooks {setup-global, remove-global, status}
|
|
2304
|
+
hook_choices = _hook_tool_choices()
|
|
2305
|
+
hook_choices_help = ", ".join(hook_choices)
|
|
2306
|
+
sub_hooks = sub.add_parser(
|
|
2307
|
+
"hooks",
|
|
2308
|
+
help=f"Manage global hooks for coding tools ({hook_choices_help})",
|
|
2309
|
+
)
|
|
2310
|
+
hooks_sub = sub_hooks.add_subparsers(dest="hooks_action", metavar="ACTION")
|
|
2311
|
+
h_setup = hooks_sub.add_parser(
|
|
2312
|
+
"setup-global",
|
|
2313
|
+
help="Install global hooks for one or more registered coding-agent adapters",
|
|
2314
|
+
)
|
|
2315
|
+
h_setup.add_argument("--tool", "-t", dest="tools", action="append", choices=hook_choices,
|
|
2316
|
+
help="Tool(s) to configure (default: all). Can be repeated.")
|
|
2317
|
+
h_remove = hooks_sub.add_parser("remove-global", help="Remove global hooks")
|
|
2318
|
+
h_remove.add_argument("--tool", "-t", dest="tools", action="append", choices=hook_choices,
|
|
2319
|
+
help="Tool(s) to remove (default: all). Can be repeated.")
|
|
2320
|
+
hooks_sub.add_parser("status", help="Show global hook status")
|
|
2321
|
+
|
|
2322
|
+
sub.add_parser("record", help="Record a trace from stdin (used by hooks)")
|
|
2323
|
+
sub.add_parser("commit-link", help="Link current commit to traces (called by git hook)")
|
|
2324
|
+
sub.add_parser("rewrite-ledger", help="Remap ledgers after rebase/amend (called by git hook)")
|
|
2325
|
+
|
|
2326
|
+
# viewer [--project /path]
|
|
2327
|
+
sub_viewer = sub.add_parser("viewer", help="Open the file viewer (browse files, git + agent-trace blame)")
|
|
2328
|
+
sub_viewer.add_argument("--project", "-p", default=None, help="Project directory (default: current directory)")
|
|
2329
|
+
|
|
2330
|
+
# blame <file>
|
|
2331
|
+
sub_blame = sub.add_parser("blame", help="Show AI attribution for a file")
|
|
2332
|
+
sub_blame.add_argument("file", help="File path to blame")
|
|
2333
|
+
sub_blame.add_argument("--line", "-l", type=int, default=None,
|
|
2334
|
+
help="Specific line number")
|
|
2335
|
+
sub_blame.add_argument("--range", "-r", default=None,
|
|
2336
|
+
help="Line range (e.g. 10-25)")
|
|
2337
|
+
sub_blame.add_argument(
|
|
2338
|
+
"--project", "-p", default=None,
|
|
2339
|
+
help="Git repo root path or registry project_id (disambiguate multi-repo cwd)",
|
|
2340
|
+
)
|
|
2341
|
+
sub_blame.add_argument("--json", action="store_true", default=False,
|
|
2342
|
+
help="Output as JSON")
|
|
2343
|
+
sub_blame.add_argument(
|
|
2344
|
+
"--show-no-attribution",
|
|
2345
|
+
action="store_true",
|
|
2346
|
+
default=False,
|
|
2347
|
+
help="Include lines not attributed to AI (NO_ATTRIBUTION); default is to omit them",
|
|
2348
|
+
)
|
|
2349
|
+
sub_blame.add_argument(
|
|
2350
|
+
"--require-attribution",
|
|
2351
|
+
action="store_true",
|
|
2352
|
+
default=False,
|
|
2353
|
+
help="Exit with non-zero status if any line is NO_ATTRIBUTION (for CI)",
|
|
2354
|
+
)
|
|
2355
|
+
|
|
2356
|
+
# context <file>
|
|
2357
|
+
sub_context = sub.add_parser("context", help="Get conversation context for AI-attributed code")
|
|
2358
|
+
sub_context.add_argument("file", help="File path to get context for")
|
|
2359
|
+
sub_context.add_argument("--lines", "-l", default=None,
|
|
2360
|
+
help="Line range (e.g. 10-25)")
|
|
2361
|
+
sub_context.add_argument(
|
|
2362
|
+
"--project", "-p", default=None,
|
|
2363
|
+
help="Git repo root path or registry project_id (disambiguate multi-repo cwd)",
|
|
2364
|
+
)
|
|
2365
|
+
sub_context.add_argument("--full", action="store_true", default=False,
|
|
2366
|
+
help="Include full conversation transcript")
|
|
2367
|
+
sub_context.add_argument("--json", action="store_true", default=False,
|
|
2368
|
+
help="Output as JSON (for machine consumption)")
|
|
2369
|
+
sub_context.add_argument("--query", "-q", default=None,
|
|
2370
|
+
help="Query to pass through for subagent instruction")
|
|
2371
|
+
|
|
2372
|
+
# rule {add,remove,show,list}
|
|
2373
|
+
sub_rule = sub.add_parser("rule", help="Manage agent rules for coding agents")
|
|
2374
|
+
rule_sub = sub_rule.add_subparsers(dest="rule_action", metavar="ACTION")
|
|
2375
|
+
|
|
2376
|
+
rule_tool_choices = list(TOOL_CHOICES)
|
|
2377
|
+
rule_tool_help = " | ".join(rule_tool_choices) or "(no tools registered)"
|
|
2378
|
+
|
|
2379
|
+
# rule add <name> --tool <...>
|
|
2380
|
+
rule_add = rule_sub.add_parser("add", help="Add a prebuilt rule")
|
|
2381
|
+
rule_add.add_argument("rule_name", help="Rule name (e.g. context-for-agents)")
|
|
2382
|
+
rule_add.add_argument("--tool", "-t", required=True, choices=rule_tool_choices,
|
|
2383
|
+
help=f"Tool to add the rule for ({rule_tool_help})")
|
|
2384
|
+
|
|
2385
|
+
# rule remove <name> --tool <...>
|
|
2386
|
+
rule_rm = rule_sub.add_parser("remove", help="Remove a rule")
|
|
2387
|
+
rule_rm.add_argument("rule_name", help="Rule name (e.g. context-for-agents)")
|
|
2388
|
+
rule_rm.add_argument("--tool", "-t", required=True, choices=rule_tool_choices,
|
|
2389
|
+
help=f"Tool to remove the rule from ({rule_tool_help})")
|
|
2390
|
+
|
|
2391
|
+
# rule show
|
|
2392
|
+
rule_sub.add_parser("show", help="Show which rules are configured")
|
|
2393
|
+
|
|
2394
|
+
# rule list
|
|
2395
|
+
rule_sub.add_parser("list", help="List available prebuilt rules")
|
|
2396
|
+
|
|
2397
|
+
# set globaluser <token>
|
|
2398
|
+
set_p = sub.add_parser("set", help="Set global configuration")
|
|
2399
|
+
set_sub = set_p.add_subparsers(dest="set_command", metavar="KEY")
|
|
2400
|
+
gu = set_sub.add_parser("globaluser", help="Set global auth token")
|
|
2401
|
+
gu.add_argument("token", help="The auth token to store globally")
|
|
2402
|
+
|
|
2403
|
+
# remove globaluser
|
|
2404
|
+
rm_p = sub.add_parser("remove", help="Remove global configuration")
|
|
2405
|
+
rm_sub = rm_p.add_subparsers(dest="remove_command", metavar="KEY")
|
|
2406
|
+
rm_sub.add_parser("globaluser", help="Remove global auth token")
|
|
2407
|
+
|
|
2408
|
+
# remote {add,list,show,set-url,set-token,remove,rename,default}
|
|
2409
|
+
sub_remote = sub.add_parser("remote", help="Manage named remotes (git remote-like)")
|
|
2410
|
+
remote_sub = sub_remote.add_subparsers(dest="remote_action", metavar="ACTION")
|
|
2411
|
+
|
|
2412
|
+
r_add = remote_sub.add_parser("add", help="Add a remote")
|
|
2413
|
+
r_add.add_argument("name", help="Remote name (e.g. origin)")
|
|
2414
|
+
r_add.add_argument("url", help="Remote URL: <scheme>://<host>/<org>/<project>")
|
|
2415
|
+
r_add.add_argument("--token", default=None, help="Auth token (stored globally)")
|
|
2416
|
+
r_add.add_argument("--token-env", default=None, help="Environment variable holding the token")
|
|
2417
|
+
r_add.add_argument(
|
|
2418
|
+
"--create", action="store_true", default=False,
|
|
2419
|
+
help="Register the project on the remote service before storing the remote",
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
r_list = remote_sub.add_parser("list", help="List remotes")
|
|
2423
|
+
|
|
2424
|
+
r_show = remote_sub.add_parser("show", help="Show remote details (token masked)")
|
|
2425
|
+
r_show.add_argument("name", help="Remote name")
|
|
2426
|
+
|
|
2427
|
+
r_seturl = remote_sub.add_parser("set-url", help="Change remote URL")
|
|
2428
|
+
r_seturl.add_argument("name", help="Remote name")
|
|
2429
|
+
r_seturl.add_argument("url", help="New URL")
|
|
2430
|
+
|
|
2431
|
+
r_settok = remote_sub.add_parser("set-token", help="Update remote auth token")
|
|
2432
|
+
r_settok.add_argument("name", help="Remote name")
|
|
2433
|
+
r_settok.add_argument("--token", default=None, help="New auth token")
|
|
2434
|
+
r_settok.add_argument("--token-env", default=None, help="Environment variable holding the token")
|
|
2435
|
+
|
|
2436
|
+
r_rm = remote_sub.add_parser("remove", help="Remove a remote")
|
|
2437
|
+
r_rm.add_argument("name", help="Remote name")
|
|
2438
|
+
|
|
2439
|
+
r_ren = remote_sub.add_parser("rename", help="Rename a remote")
|
|
2440
|
+
r_ren.add_argument("old_name", help="Current name")
|
|
2441
|
+
r_ren.add_argument("new_name", help="New name")
|
|
2442
|
+
|
|
2443
|
+
r_def = remote_sub.add_parser("default", help="Set default remote")
|
|
2444
|
+
r_def.add_argument("name", help="Remote name to set as default")
|
|
2445
|
+
|
|
2446
|
+
# push
|
|
2447
|
+
sub_push = sub.add_parser("push", help="Push local data to a remote")
|
|
2448
|
+
sub_push.add_argument("--remote", default=None, help="Remote name (default: auto)")
|
|
2449
|
+
sub_push.add_argument("--full", action="store_true", default=False,
|
|
2450
|
+
help="Include unattributed traces (default: attributed only)")
|
|
2451
|
+
sub_push.add_argument("--only", default=None, choices=["traces", "ledgers", "commit-links"],
|
|
2452
|
+
help="Push only one artifact type")
|
|
2453
|
+
sub_push.add_argument("--since", default=None, help="Push only since this timestamp/commit")
|
|
2454
|
+
sub_push.add_argument("--dry-run", action="store_true", default=False,
|
|
2455
|
+
help="Show what would be pushed without sending")
|
|
2456
|
+
|
|
2457
|
+
# pull
|
|
2458
|
+
sub_pull = sub.add_parser("pull", help="Pull remote data into local storage")
|
|
2459
|
+
sub_pull.add_argument("--remote", default=None, help="Remote name (default: auto)")
|
|
2460
|
+
sub_pull.add_argument("--since", default=None, help="Pull only since this timestamp")
|
|
2461
|
+
sub_pull.add_argument("--dry-run", action="store_true", default=False,
|
|
2462
|
+
help="Show what would be pulled without writing")
|
|
2463
|
+
|
|
2464
|
+
# sync
|
|
2465
|
+
sub_sync = sub.add_parser("sync", help="Push + pull in one go")
|
|
2466
|
+
sub_sync.add_argument("--remote", default=None, help="Remote name (default: auto)")
|
|
2467
|
+
|
|
2468
|
+
p_projects = sub.add_parser("projects", help="List registered projects (or: projects show <id>)")
|
|
2469
|
+
p_projects.add_argument("projects_args", nargs="*", default=[], help="show <project_id>")
|
|
2470
|
+
|
|
2471
|
+
p_adopt = sub.add_parser("adopt", help="Register a repo and print its project_id")
|
|
2472
|
+
p_adopt.add_argument(
|
|
2473
|
+
"adopt_path",
|
|
2474
|
+
nargs="?",
|
|
2475
|
+
default=".",
|
|
2476
|
+
help="Path to repository (default: current directory)",
|
|
2477
|
+
)
|
|
2478
|
+
|
|
2479
|
+
# project {create}
|
|
2480
|
+
p_project = sub.add_parser("project", help="Server-side project administration")
|
|
2481
|
+
project_sub = p_project.add_subparsers(dest="project_action", metavar="ACTION")
|
|
2482
|
+
|
|
2483
|
+
p_proj_create = project_sub.add_parser(
|
|
2484
|
+
"create",
|
|
2485
|
+
help="Register a project on a remote service (POST /api/v1/projects)",
|
|
2486
|
+
)
|
|
2487
|
+
p_proj_create.add_argument(
|
|
2488
|
+
"url",
|
|
2489
|
+
help="Remote URL: <scheme>://<host>/<org>/<project>",
|
|
2490
|
+
)
|
|
2491
|
+
p_proj_create.add_argument("--token", default=None, help="Org-scoped token (with projects:write)")
|
|
2492
|
+
p_proj_create.add_argument(
|
|
2493
|
+
"--token-env", default=None,
|
|
2494
|
+
help="Environment variable holding the token",
|
|
2495
|
+
)
|
|
2496
|
+
p_proj_create.add_argument("--name", default=None, help="Optional human-readable display name")
|
|
2497
|
+
p_proj_create.add_argument("--description", default=None, help="Optional description")
|
|
2498
|
+
|
|
2499
|
+
# notes {show, attach, rebuild, backfill, strip, push, pull}
|
|
2500
|
+
sub_notes = sub.add_parser("notes", help="Git notes (refs/notes/agent-trace)")
|
|
2501
|
+
notes_sub = sub_notes.add_subparsers(dest="notes_action", metavar="ACTION", required=True)
|
|
2502
|
+
|
|
2503
|
+
ns_show = notes_sub.add_parser("show", help="Print JSON note for a commit")
|
|
2504
|
+
ns_show.add_argument("commit", nargs="?", default="HEAD", help="Commit (default: HEAD)")
|
|
2505
|
+
|
|
2506
|
+
ns_attach = notes_sub.add_parser("attach", help="Build note from local ledger and attach")
|
|
2507
|
+
ns_attach.add_argument("commit", nargs="?", default="HEAD", help="Commit (default: HEAD)")
|
|
2508
|
+
ns_attach.add_argument("--include-ledger", action="store_true", help="Include ledger section")
|
|
2509
|
+
ns_attach.add_argument("--no-include-ledger", action="store_true", help="Omit ledger section")
|
|
2510
|
+
ns_attach.add_argument("--include-summary", action="store_true")
|
|
2511
|
+
ns_attach.add_argument("--no-include-summary", action="store_true")
|
|
2512
|
+
ns_attach.add_argument("--include-prompts", action="store_true")
|
|
2513
|
+
ns_attach.add_argument("--no-include-prompts", action="store_true")
|
|
2514
|
+
ns_attach.add_argument(
|
|
2515
|
+
"--include-all-session-conversations",
|
|
2516
|
+
action="store_true",
|
|
2517
|
+
help="Include all_session_conversations section (staging window)",
|
|
2518
|
+
)
|
|
2519
|
+
ns_attach.add_argument(
|
|
2520
|
+
"--no-include-all-session-conversations",
|
|
2521
|
+
action="store_true",
|
|
2522
|
+
help="Omit all_session_conversations section",
|
|
2523
|
+
)
|
|
2524
|
+
|
|
2525
|
+
ns_rebuild = notes_sub.add_parser("rebuild", help="Rebuild notes from local ledgers for a commit range")
|
|
2526
|
+
ns_rebuild.add_argument("range_spec", help="Range for git rev-list (e.g. HEAD~10..HEAD)")
|
|
2527
|
+
ns_rebuild.add_argument("--include-ledger", action="store_true")
|
|
2528
|
+
ns_rebuild.add_argument("--no-include-ledger", action="store_true")
|
|
2529
|
+
ns_rebuild.add_argument("--include-summary", action="store_true")
|
|
2530
|
+
ns_rebuild.add_argument("--no-include-summary", action="store_true")
|
|
2531
|
+
ns_rebuild.add_argument("--include-prompts", action="store_true")
|
|
2532
|
+
ns_rebuild.add_argument("--no-include-prompts", action="store_true")
|
|
2533
|
+
ns_rebuild.add_argument("--include-all-session-conversations", action="store_true")
|
|
2534
|
+
ns_rebuild.add_argument("--no-include-all-session-conversations", action="store_true")
|
|
2535
|
+
|
|
2536
|
+
ns_backfill = notes_sub.add_parser("backfill", help="Rebuild notes for commits (optional --since)")
|
|
2537
|
+
ns_backfill.add_argument("--since", default=None, help="git rev-list --since (e.g. 2026-01-01)")
|
|
2538
|
+
ns_backfill.add_argument("--include-ledger", action="store_true")
|
|
2539
|
+
ns_backfill.add_argument("--no-include-ledger", action="store_true")
|
|
2540
|
+
ns_backfill.add_argument("--include-summary", action="store_true")
|
|
2541
|
+
ns_backfill.add_argument("--no-include-summary", action="store_true")
|
|
2542
|
+
ns_backfill.add_argument("--include-prompts", action="store_true")
|
|
2543
|
+
ns_backfill.add_argument("--no-include-prompts", action="store_true")
|
|
2544
|
+
ns_backfill.add_argument("--include-all-session-conversations", action="store_true")
|
|
2545
|
+
ns_backfill.add_argument("--no-include-all-session-conversations", action="store_true")
|
|
2546
|
+
|
|
2547
|
+
ns_strip = notes_sub.add_parser("strip", help="Remove optional sections from a note")
|
|
2548
|
+
ns_strip.add_argument("commit", nargs="?", default="HEAD")
|
|
2549
|
+
ns_strip.add_argument("--ledger", dest="strip_ledger", action="store_true")
|
|
2550
|
+
ns_strip.add_argument("--summary", dest="strip_summary", action="store_true")
|
|
2551
|
+
ns_strip.add_argument("--prompts", dest="strip_prompts", action="store_true")
|
|
2552
|
+
ns_strip.add_argument(
|
|
2553
|
+
"--all-session-conversations",
|
|
2554
|
+
dest="strip_all_session_conversations",
|
|
2555
|
+
action="store_true",
|
|
2556
|
+
)
|
|
2557
|
+
|
|
2558
|
+
ns_npush = notes_sub.add_parser("push", help="Push refs/notes/agent-trace to a remote")
|
|
2559
|
+
ns_npush.add_argument("--remote", default="origin")
|
|
2560
|
+
|
|
2561
|
+
ns_npull = notes_sub.add_parser("pull", help="Fetch refs/notes/agent-trace from a remote")
|
|
2562
|
+
ns_npull.add_argument("--remote", default="origin")
|
|
2563
|
+
|
|
2564
|
+
sub_summary = sub.add_parser(
|
|
2565
|
+
"summary",
|
|
2566
|
+
help="Pluggable transcript summaries (command reads raw transcript on stdin, prints summary on stdout)",
|
|
2567
|
+
)
|
|
2568
|
+
sum_sub = sub_summary.add_subparsers(dest="summary_action", metavar="ACTION", required=True)
|
|
2569
|
+
s_en = sum_sub.add_parser("enable", help="Enable and set the summary command")
|
|
2570
|
+
s_en.add_argument(
|
|
2571
|
+
"--command",
|
|
2572
|
+
dest="summary_command",
|
|
2573
|
+
required=True,
|
|
2574
|
+
help="Executable: raw transcript on stdin, summary text on stdout",
|
|
2575
|
+
)
|
|
2576
|
+
s_en.add_argument(
|
|
2577
|
+
"--timeout",
|
|
2578
|
+
dest="summary_timeout",
|
|
2579
|
+
type=int,
|
|
2580
|
+
default=None,
|
|
2581
|
+
help="Timeout seconds (default 30)",
|
|
2582
|
+
)
|
|
2583
|
+
sum_sub.add_parser("presets", help="List built-in summary presets")
|
|
2584
|
+
s_use = sum_sub.add_parser("use", help="Enable a built-in summary preset")
|
|
2585
|
+
s_use.add_argument("preset_alias", choices=PRESET_ALIASES, help="Preset alias")
|
|
2586
|
+
s_use.add_argument(
|
|
2587
|
+
"--model",
|
|
2588
|
+
default=None,
|
|
2589
|
+
help="Model name for ollama-summary (ignored by other presets)",
|
|
2590
|
+
)
|
|
2591
|
+
s_use.add_argument(
|
|
2592
|
+
"--timeout",
|
|
2593
|
+
dest="summary_timeout",
|
|
2594
|
+
type=int,
|
|
2595
|
+
default=None,
|
|
2596
|
+
help="Timeout seconds (default 30)",
|
|
2597
|
+
)
|
|
2598
|
+
s_pr = sum_sub.add_parser(
|
|
2599
|
+
"preset-run",
|
|
2600
|
+
help=argparse.SUPPRESS, # internal: summary.command target for built-in presets
|
|
2601
|
+
)
|
|
2602
|
+
s_pr.add_argument("preset_alias", choices=PRESET_ALIASES)
|
|
2603
|
+
s_pr.add_argument("--model", default=None)
|
|
2604
|
+
sum_sub.add_parser("disable", help="Disable session-end summaries")
|
|
2605
|
+
s_gen = sum_sub.add_parser(
|
|
2606
|
+
"generate",
|
|
2607
|
+
help="Re-run summary for a conversation id or every id in a session",
|
|
2608
|
+
)
|
|
2609
|
+
s_gen.add_argument(
|
|
2610
|
+
"--conversation",
|
|
2611
|
+
dest="conversation_id",
|
|
2612
|
+
default=None,
|
|
2613
|
+
help="A specific conversation_id (64-hex sha256 over the original transcript URL)",
|
|
2614
|
+
)
|
|
2615
|
+
s_gen.add_argument(
|
|
2616
|
+
"--session-id",
|
|
2617
|
+
dest="session_id",
|
|
2618
|
+
default=None,
|
|
2619
|
+
help="session_id; regenerates every conversation id referenced by traces in that session",
|
|
2620
|
+
)
|
|
2621
|
+
s_show = sum_sub.add_parser(
|
|
2622
|
+
"show",
|
|
2623
|
+
help="Show {conversation_id: summary} for a commit",
|
|
2624
|
+
)
|
|
2625
|
+
s_show.add_argument("commit", nargs="?", default="HEAD", help="Commit (default HEAD)")
|
|
2626
|
+
|
|
2627
|
+
args = parser.parse_args()
|
|
2628
|
+
|
|
2629
|
+
if getattr(args, "telemetry", None) is not None:
|
|
2630
|
+
if args.command is not None:
|
|
2631
|
+
parser.error(
|
|
2632
|
+
"--telemetry cannot be combined with a subcommand "
|
|
2633
|
+
"(example: agent-trace --telemetry status)",
|
|
2634
|
+
)
|
|
2635
|
+
cmd_telemetry_control(args)
|
|
2636
|
+
return
|
|
2637
|
+
|
|
2638
|
+
if args.command is None:
|
|
2639
|
+
parser.print_help()
|
|
2640
|
+
sys.exit(0)
|
|
2641
|
+
|
|
2642
|
+
start = time.monotonic()
|
|
2643
|
+
try:
|
|
2644
|
+
_run_subcommand(args, set_p, rm_p)
|
|
2645
|
+
except SystemExit as exc:
|
|
2646
|
+
code = exc.code
|
|
2647
|
+
exit_code = (
|
|
2648
|
+
0
|
|
2649
|
+
if code is None
|
|
2650
|
+
else (code if isinstance(code, int) else (1 if code else 0))
|
|
2651
|
+
)
|
|
2652
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
2653
|
+
maybe_report_cli_run(
|
|
2654
|
+
version=VERSION,
|
|
2655
|
+
command=args.command,
|
|
2656
|
+
exit_code=exit_code,
|
|
2657
|
+
duration_ms=duration_ms,
|
|
2658
|
+
)
|
|
2659
|
+
raise
|
|
2660
|
+
except BaseException:
|
|
2661
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
2662
|
+
maybe_report_cli_run(
|
|
2663
|
+
version=VERSION,
|
|
2664
|
+
command=args.command,
|
|
2665
|
+
exit_code=1,
|
|
2666
|
+
duration_ms=duration_ms,
|
|
2667
|
+
)
|
|
2668
|
+
raise
|
|
2669
|
+
else:
|
|
2670
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
2671
|
+
maybe_report_cli_run(
|
|
2672
|
+
version=VERSION,
|
|
2673
|
+
command=args.command,
|
|
2674
|
+
exit_code=0,
|
|
2675
|
+
duration_ms=duration_ms,
|
|
2676
|
+
)
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
if __name__ == "__main__":
|
|
2680
|
+
main()
|