gator-command 1.0.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.
- gator_command/__init__.py +2 -0
- gator_command/cli.py +137 -0
- gator_command/scripts/crawler.py +633 -0
- gator_command/scripts/dashboard/dashboard.css +982 -0
- gator_command/scripts/dashboard/dashboard.html +84 -0
- gator_command/scripts/dashboard/dashboard.js +419 -0
- gator_command/scripts/dashboard/views/audit.js +270 -0
- gator_command/scripts/dashboard/views/fleet.js +307 -0
- gator_command/scripts/dashboard/views/repo.js +599 -0
- gator_command/scripts/dashboard/views/settings.js +173 -0
- gator_command/scripts/dashboard/views/updates.js +308 -0
- gator_command/scripts/enforcer-prompt.md +22 -0
- gator_command/scripts/extract-claude-sessions.py +489 -0
- gator_command/scripts/extract-codex-sessions.py +477 -0
- gator_command/scripts/extract-gemini-sessions.py +410 -0
- gator_command/scripts/gator-audit.py +956 -0
- gator_command/scripts/gator-charter-draft.py +919 -0
- gator_command/scripts/gator-charter-lint.py +427 -0
- gator_command/scripts/gator-charter-verify.py +606 -0
- gator_command/scripts/gator-dashboard.py +1271 -0
- gator_command/scripts/gator-deploy.py +916 -0
- gator_command/scripts/gator-drift.py +569 -0
- gator_command/scripts/gator-enforce.py +82 -0
- gator_command/scripts/gator-fleet-intel.py +460 -0
- gator_command/scripts/gator-fleet-report.py +615 -0
- gator_command/scripts/gator-init-command-post.py +315 -0
- gator_command/scripts/gator-init.py +434 -0
- gator_command/scripts/gator-machine-id.py +153 -0
- gator_command/scripts/gator-policy-status.py +631 -0
- gator_command/scripts/gator-pulse.py +459 -0
- gator_command/scripts/gator-repo-status.py +649 -0
- gator_command/scripts/gator-session-common.py +372 -0
- gator_command/scripts/gator-session-sink.py +831 -0
- gator_command/scripts/gator-sessions.py +1244 -0
- gator_command/scripts/gator-update.py +615 -0
- gator_command/scripts/gator-version.py +38 -0
- gator_command/scripts/gator_core.py +489 -0
- gator_command/scripts/gator_remote.py +381 -0
- gator_command/scripts/gator_runtime.py +142 -0
- gator_command/scripts/gatorize-actions.sh +989 -0
- gator_command/scripts/gatorize-lib.sh +166 -0
- gator_command/scripts/gatorize-post.sh +394 -0
- gator_command/scripts/gatorize.py +1163 -0
- gator_command/scripts/gatorize.sh +185 -0
- gator_command/scripts/generate_markdown.py +212 -0
- gator_command/scripts/generate_wiki.py +424 -0
- gator_command/scripts/graph_health.py +780 -0
- gator_command/scripts/memex-lint.py +286 -0
- gator_command/scripts/memex-lint.sh +205 -0
- gator_command/scripts/memex.py +1472 -0
- gator_command/scripts/memex_formatters.py +191 -0
- gator_command/scripts/memex_state.py +236 -0
- gator_command/scripts/spawn.py +650 -0
- gator_command/templates/gator-starter/blueprints/README.md +32 -0
- gator_command/templates/gator-starter/charterignore +53 -0
- gator_command/templates/gator-starter/charters/README.md +178 -0
- gator_command/templates/gator-starter/charters/_template.md +31 -0
- gator_command/templates/gator-starter/commands/commit.md +33 -0
- gator_command/templates/gator-starter/commands/init.md +11 -0
- gator_command/templates/gator-starter/commands/update.md +5 -0
- gator_command/templates/gator-starter/constitution.md +165 -0
- gator_command/templates/gator-starter/field-guides/README.md +25 -0
- gator_command/templates/gator-starter/gator-start-up.md +119 -0
- gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
- gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
- gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
- gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
- gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
- gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
- gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
- gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
- gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
- gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
- gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
- gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
- gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
- gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
- gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
- gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
- gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
- gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
- gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
- gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
- gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
- gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
- gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
- gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
- gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
- gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
- gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
- gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
- gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
- gator_command/templates/gator-starter/sessions/.gitignore +7 -0
- gator_command/templates/gator-starter/vault/.gitkeep +0 -0
- gator_command/templates/gator-starter/whiteboard.md +5 -0
- gator_command-1.0.0.dist-info/METADATA +122 -0
- gator_command-1.0.0.dist-info/RECORD +110 -0
- gator_command-1.0.0.dist-info/WHEEL +5 -0
- gator_command-1.0.0.dist-info/entry_points.txt +2 -0
- gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
- gator_command-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gator sessions — Audit infrastructure CLI for AI coding sessions.
|
|
4
|
+
|
|
5
|
+
Orchestrates the vendor-specific extraction scripts and presents
|
|
6
|
+
a unified automation surface for enterprise session management.
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
gator sessions index — show all discoverable sessions across vendors
|
|
10
|
+
gator sessions manifest — emit JSON manifest for enterprise automation
|
|
11
|
+
gator sessions export — write normalized session files to spool dir
|
|
12
|
+
gator sessions pending — show sessions not yet exported
|
|
13
|
+
|
|
14
|
+
Gator does not own the audit backend. It produces a portable audit index
|
|
15
|
+
and normalized session evidence. Enterprise automation collects and stores
|
|
16
|
+
it wherever they already trust.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python gator-command/scripts/gator-sessions.py index
|
|
20
|
+
python gator-command/scripts/gator-sessions.py manifest --since 24h --json
|
|
21
|
+
python gator-command/scripts/gator-sessions.py export --pending
|
|
22
|
+
python gator-command/scripts/gator-sessions.py pending
|
|
23
|
+
|
|
24
|
+
@reads: ~/.claude/, ~/.codex/, ~/.gemini/, ~/.gator/machine-id, spool state
|
|
25
|
+
@writes: ~/.gator/session-spool/ (normalized exports), stdout
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import glob
|
|
30
|
+
import io
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
from datetime import datetime, timedelta, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
# Add scripts dir to path for imports
|
|
39
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
40
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Lazy vendor imports (only load what's available)
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _import_vendor(module_name):
|
|
48
|
+
"""Import a vendor extraction module by file name (with hyphens)."""
|
|
49
|
+
try:
|
|
50
|
+
import importlib.util
|
|
51
|
+
# Files use hyphens: extract-claude-sessions.py
|
|
52
|
+
script_path = SCRIPTS_DIR / f"{module_name}.py"
|
|
53
|
+
if not script_path.exists():
|
|
54
|
+
return None
|
|
55
|
+
spec = importlib.util.spec_from_file_location(
|
|
56
|
+
module_name.replace("-", "_"), script_path
|
|
57
|
+
)
|
|
58
|
+
mod = importlib.util.module_from_spec(spec)
|
|
59
|
+
spec.loader.exec_module(mod)
|
|
60
|
+
return mod
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _spool_slug(session_id, source_path=""):
|
|
66
|
+
"""Generate a filesystem-safe unique spool filename slug.
|
|
67
|
+
|
|
68
|
+
Hashes session_id + source_path together. The source_path
|
|
69
|
+
disambiguates when two different files share the same internal
|
|
70
|
+
session ID (verified real case in Gemini storage).
|
|
71
|
+
"""
|
|
72
|
+
import hashlib
|
|
73
|
+
key = f"{session_id}|{source_path}"
|
|
74
|
+
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Constants
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
GATOR_USER_DIR = Path.home() / ".gator"
|
|
82
|
+
SPOOL_DIR = GATOR_USER_DIR / "session-spool"
|
|
83
|
+
SPOOL_STATE_FILE = SPOOL_DIR / ".exported.json"
|
|
84
|
+
MANIFEST_DIR = GATOR_USER_DIR / "turn-manifests"
|
|
85
|
+
|
|
86
|
+
STOPWORDS = {
|
|
87
|
+
"the", "and", "this", "that", "with", "for", "are", "but", "not", "you",
|
|
88
|
+
"all", "can", "had", "her", "was", "one", "our", "out", "has", "have",
|
|
89
|
+
"from", "they", "been", "said", "each", "which", "their", "will", "way",
|
|
90
|
+
"about", "many", "then", "them", "would", "like", "more", "some", "time",
|
|
91
|
+
"very", "when", "come", "could", "made", "after", "also", "did", "just",
|
|
92
|
+
"these", "than", "other", "into", "only", "new", "its", "what", "how",
|
|
93
|
+
"who", "may", "any", "use", "here", "there", "where", "does", "should",
|
|
94
|
+
"need", "please", "sure", "let", "now", "see", "get", "make", "know",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Machine identity — delegated to shared module
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def get_machine_identity():
|
|
103
|
+
"""Get stable machine identity (delegates to shared module)."""
|
|
104
|
+
common = _import_vendor("gator-session-common")
|
|
105
|
+
if common:
|
|
106
|
+
return common.get_machine_identity()
|
|
107
|
+
# Fallback if shared module unavailable
|
|
108
|
+
import platform
|
|
109
|
+
return {"id": "unknown", "hostname": platform.node(), "label": platform.node()}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Unified session discovery
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def discover_all_sessions():
|
|
117
|
+
"""Discover sessions across all installed vendors.
|
|
118
|
+
|
|
119
|
+
Returns list of normalized session dicts with vendor, project,
|
|
120
|
+
session_id, path, date, size, modified.
|
|
121
|
+
"""
|
|
122
|
+
all_sessions = []
|
|
123
|
+
|
|
124
|
+
# Claude Code
|
|
125
|
+
claude_mod = _import_vendor("extract-claude-sessions")
|
|
126
|
+
if claude_mod:
|
|
127
|
+
try:
|
|
128
|
+
projects = claude_mod.discover_projects()
|
|
129
|
+
for p in projects:
|
|
130
|
+
for s in p["sessions"]:
|
|
131
|
+
all_sessions.append({
|
|
132
|
+
"vendor": "claude",
|
|
133
|
+
"project": p["name"],
|
|
134
|
+
"session_id": s["uuid"],
|
|
135
|
+
"path": str(s["path"]),
|
|
136
|
+
"date": s.get("modified", "")[:10],
|
|
137
|
+
"size": s["path"].stat().st_size if s["path"].exists() else 0,
|
|
138
|
+
"modified": s["modified"],
|
|
139
|
+
"lines": s.get("lines", 0),
|
|
140
|
+
})
|
|
141
|
+
except Exception as e:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
# Codex CLI
|
|
145
|
+
codex_mod = _import_vendor("extract-codex-sessions")
|
|
146
|
+
if codex_mod:
|
|
147
|
+
try:
|
|
148
|
+
sessions = codex_mod.discover_sessions()
|
|
149
|
+
for s in sessions:
|
|
150
|
+
# Enrich project from session_meta cwd (first line)
|
|
151
|
+
project = ""
|
|
152
|
+
try:
|
|
153
|
+
with open(s["path"], encoding="utf-8", errors="replace") as sf:
|
|
154
|
+
for line in sf:
|
|
155
|
+
data = json.loads(line.strip())
|
|
156
|
+
if data.get("type") == "session_meta":
|
|
157
|
+
cwd = data.get("payload", {}).get("cwd", "")
|
|
158
|
+
if cwd:
|
|
159
|
+
project = Path(cwd).name
|
|
160
|
+
break
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
all_sessions.append({
|
|
164
|
+
"vendor": "codex",
|
|
165
|
+
"project": project,
|
|
166
|
+
"session_id": s["uuid"],
|
|
167
|
+
"path": str(s["path"]),
|
|
168
|
+
"date": s.get("date", ""),
|
|
169
|
+
"size": s.get("size", 0),
|
|
170
|
+
"modified": s["modified"],
|
|
171
|
+
"lines": s.get("lines", 0),
|
|
172
|
+
})
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# Gemini CLI
|
|
177
|
+
gemini_mod = _import_vendor("extract-gemini-sessions")
|
|
178
|
+
if gemini_mod:
|
|
179
|
+
try:
|
|
180
|
+
sessions = gemini_mod.discover_sessions()
|
|
181
|
+
for s in sessions:
|
|
182
|
+
all_sessions.append({
|
|
183
|
+
"vendor": "gemini",
|
|
184
|
+
"project": s.get("project", ""),
|
|
185
|
+
"session_id": s["uuid"],
|
|
186
|
+
"path": str(s["path"]),
|
|
187
|
+
"date": s.get("date", ""),
|
|
188
|
+
"size": s.get("size", 0),
|
|
189
|
+
"modified": s["modified"],
|
|
190
|
+
"lines": s.get("lines", 0),
|
|
191
|
+
})
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
# Sort by modified date (most recent first)
|
|
196
|
+
all_sessions.sort(key=lambda x: x.get("modified", ""), reverse=True)
|
|
197
|
+
return all_sessions
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Spool state management
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def load_exported_state():
|
|
205
|
+
"""Load the set of session IDs already exported to spool."""
|
|
206
|
+
if not SPOOL_STATE_FILE.exists():
|
|
207
|
+
return set()
|
|
208
|
+
try:
|
|
209
|
+
data = json.loads(SPOOL_STATE_FILE.read_text(encoding="utf-8"))
|
|
210
|
+
return set(data.get("exported", []))
|
|
211
|
+
except (json.JSONDecodeError, OSError):
|
|
212
|
+
return set()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def save_exported_state(exported_ids):
|
|
216
|
+
"""Save the set of exported session IDs."""
|
|
217
|
+
SPOOL_DIR.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
data = {
|
|
219
|
+
"exported": sorted(exported_ids),
|
|
220
|
+
"last_updated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
221
|
+
}
|
|
222
|
+
SPOOL_STATE_FILE.write_text(
|
|
223
|
+
json.dumps(data, indent=2) + "\n",
|
|
224
|
+
encoding="utf-8",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_pending_sessions(all_sessions):
|
|
229
|
+
"""Return sessions not yet exported to spool."""
|
|
230
|
+
exported = load_exported_state()
|
|
231
|
+
return [s for s in all_sessions if f"{s['vendor']}-{_spool_slug(s['session_id'], s.get('path', ''))}" not in exported]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Time filter
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def parse_since(since_str):
|
|
239
|
+
"""Parse a --since argument like '24h', '7d', '1w' into a datetime."""
|
|
240
|
+
if not since_str:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
match = re.match(r'^(\d+)\s*([hdwm])$', since_str.lower())
|
|
244
|
+
if not match:
|
|
245
|
+
# Try ISO date
|
|
246
|
+
try:
|
|
247
|
+
return datetime.fromisoformat(since_str).replace(tzinfo=timezone.utc)
|
|
248
|
+
except ValueError:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
value = int(match.group(1))
|
|
252
|
+
unit = match.group(2)
|
|
253
|
+
now = datetime.now(timezone.utc)
|
|
254
|
+
|
|
255
|
+
if unit == "h":
|
|
256
|
+
return now - timedelta(hours=value)
|
|
257
|
+
elif unit == "d":
|
|
258
|
+
return now - timedelta(days=value)
|
|
259
|
+
elif unit == "w":
|
|
260
|
+
return now - timedelta(weeks=value)
|
|
261
|
+
elif unit == "m":
|
|
262
|
+
return now - timedelta(days=value * 30)
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def filter_sessions_since(sessions, since_dt):
|
|
267
|
+
"""Filter sessions modified after the given datetime."""
|
|
268
|
+
if not since_dt:
|
|
269
|
+
return sessions
|
|
270
|
+
|
|
271
|
+
since_str = since_dt.strftime("%Y-%m-%d %H:%M")
|
|
272
|
+
return [s for s in sessions if s.get("modified", "") >= since_str]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Turn manifest extraction helpers (pure functions)
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def extract_turn_text(turn):
|
|
280
|
+
"""Coerce turn content to a flat string."""
|
|
281
|
+
content = turn.get("content", "")
|
|
282
|
+
if isinstance(content, str):
|
|
283
|
+
return content
|
|
284
|
+
if isinstance(content, list):
|
|
285
|
+
parts = []
|
|
286
|
+
for item in content:
|
|
287
|
+
if isinstance(item, str):
|
|
288
|
+
parts.append(item)
|
|
289
|
+
elif isinstance(item, dict):
|
|
290
|
+
parts.append(item.get("text", ""))
|
|
291
|
+
return " ".join(parts)
|
|
292
|
+
return str(content)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def extract_tool_types(turn):
|
|
296
|
+
"""Return sorted unique tool names from tool_calls."""
|
|
297
|
+
tool_calls = turn.get("tool_calls", [])
|
|
298
|
+
names = set()
|
|
299
|
+
for tc in tool_calls:
|
|
300
|
+
name = tc.get("tool", "") or tc.get("name", "")
|
|
301
|
+
if name:
|
|
302
|
+
names.add(name)
|
|
303
|
+
return sorted(names)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _make_preview(text, max_len=180):
|
|
307
|
+
"""Collapse whitespace and truncate to max_len."""
|
|
308
|
+
collapsed = re.sub(r'\s+', ' ', text).strip()
|
|
309
|
+
if len(collapsed) <= max_len:
|
|
310
|
+
return collapsed
|
|
311
|
+
return collapsed[:max_len - 3] + "..."
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def extract_mentions_files(text):
|
|
315
|
+
"""Extract file mentions from text. Returns deduplicated list, cap 20."""
|
|
316
|
+
files = []
|
|
317
|
+
seen = set()
|
|
318
|
+
|
|
319
|
+
# Backtick-quoted paths and double-quoted paths with extensions
|
|
320
|
+
for m in re.finditer(r'[`"]([a-zA-Z0-9_./-]+\.[a-zA-Z]{1,10})[`"]', text):
|
|
321
|
+
f = m.group(1)
|
|
322
|
+
if f not in seen and not f.startswith("http"):
|
|
323
|
+
seen.add(f)
|
|
324
|
+
files.append(f)
|
|
325
|
+
|
|
326
|
+
# Bare paths with code extensions (word boundary)
|
|
327
|
+
code_exts = r'\.(py|js|ts|tsx|jsx|rs|go|java|rb|sh|md|yaml|yml|json|toml|sql|css|html)'
|
|
328
|
+
for m in re.finditer(r'(?<![`"\w])([a-zA-Z0-9_][\w./-]*' + code_exts + r')(?![`"\w])', text):
|
|
329
|
+
f = m.group(1)
|
|
330
|
+
if f not in seen:
|
|
331
|
+
seen.add(f)
|
|
332
|
+
files.append(f)
|
|
333
|
+
|
|
334
|
+
return files[:20]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def extract_mentions_functions(text):
|
|
338
|
+
"""Extract function/method mentions. Returns bare names, cap 20."""
|
|
339
|
+
funcs = []
|
|
340
|
+
seen = set()
|
|
341
|
+
|
|
342
|
+
# foo() or Class.method() → bare name without parens
|
|
343
|
+
for m in re.finditer(r'(?<!\w)([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\s*\(', text):
|
|
344
|
+
name = m.group(1)
|
|
345
|
+
# Skip common non-function patterns
|
|
346
|
+
if name.lower() in ('if', 'for', 'while', 'return', 'print', 'and', 'or', 'not',
|
|
347
|
+
'in', 'is', 'with', 'as', 'from', 'import', 'class', 'elif',
|
|
348
|
+
'http', 'https', 'e', 'f', 'str', 'int', 'list', 'dict',
|
|
349
|
+
'set', 'type', 'len', 'range', 'true', 'false', 'none'):
|
|
350
|
+
continue
|
|
351
|
+
if name not in seen:
|
|
352
|
+
seen.add(name)
|
|
353
|
+
funcs.append(name)
|
|
354
|
+
|
|
355
|
+
# def foo( → foo
|
|
356
|
+
for m in re.finditer(r'\bdef\s+([a-zA-Z_]\w*)\s*\(', text):
|
|
357
|
+
name = m.group(1)
|
|
358
|
+
if name not in seen:
|
|
359
|
+
seen.add(name)
|
|
360
|
+
funcs.append(name)
|
|
361
|
+
|
|
362
|
+
return funcs[:20]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def extract_keywords(text, top_n=8):
|
|
366
|
+
"""Extract top-N keywords by frequency, excluding stopwords."""
|
|
367
|
+
from collections import Counter
|
|
368
|
+
# Tokenize: alphanumeric words, 3+ chars
|
|
369
|
+
words = re.findall(r'[a-zA-Z]{3,}', text.lower())
|
|
370
|
+
filtered = [w for w in words if w not in STOPWORDS]
|
|
371
|
+
counts = Counter(filtered)
|
|
372
|
+
return [word for word, _ in counts.most_common(top_n)]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# Turn manifest build functions
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def build_turn_record(turn):
|
|
380
|
+
"""Build a single manifest turn entry from a spool turn."""
|
|
381
|
+
text = extract_turn_text(turn)
|
|
382
|
+
return {
|
|
383
|
+
"seq": turn.get("seq", 0),
|
|
384
|
+
"role": turn.get("role", ""),
|
|
385
|
+
"vendor_type": turn.get("item_type", "") or turn.get("type", turn.get("role", "")),
|
|
386
|
+
"timestamp": turn.get("timestamp", ""),
|
|
387
|
+
"chars": len(text),
|
|
388
|
+
"has_tool_calls": bool(turn.get("tool_calls")),
|
|
389
|
+
"tool_types": extract_tool_types(turn),
|
|
390
|
+
"mentions_files": extract_mentions_files(text),
|
|
391
|
+
"mentions_functions": extract_mentions_functions(text),
|
|
392
|
+
"keywords": extract_keywords(text),
|
|
393
|
+
"preview": _make_preview(text),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def build_turn_manifest(session_export):
|
|
398
|
+
"""Build a full manifest dict from a spool export dict."""
|
|
399
|
+
metadata = session_export.get("metadata", {})
|
|
400
|
+
session_id = metadata.get("session_id", "")
|
|
401
|
+
source_path = metadata.get("source_path", "") or ""
|
|
402
|
+
row_key = _spool_slug(session_id, source_path)
|
|
403
|
+
|
|
404
|
+
turns_raw = session_export.get("turns", [])
|
|
405
|
+
# Ensure each turn has a seq number
|
|
406
|
+
manifest_turns = []
|
|
407
|
+
for i, turn in enumerate(turns_raw):
|
|
408
|
+
if "seq" not in turn:
|
|
409
|
+
turn = dict(turn, seq=i + 1)
|
|
410
|
+
manifest_turns.append(build_turn_record(turn))
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
"schema": "gator-turn-manifest-v1",
|
|
414
|
+
"row_key": row_key,
|
|
415
|
+
"session_id": session_id,
|
|
416
|
+
"vendor": session_export.get("vendor", ""),
|
|
417
|
+
"repo": metadata.get("repo", "") or metadata.get("project", ""),
|
|
418
|
+
"source_path": source_path,
|
|
419
|
+
"spool_file": "", # filled by caller
|
|
420
|
+
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
421
|
+
"turn_count": len(manifest_turns),
|
|
422
|
+
"turns": manifest_turns,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def write_turn_manifest(manifest, out_path):
|
|
427
|
+
"""Write manifest JSON to disk, creating parent dirs."""
|
|
428
|
+
out_path = Path(out_path)
|
|
429
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
out_path.write_text(
|
|
431
|
+
json.dumps(manifest, indent=2, default=str) + "\n",
|
|
432
|
+
encoding="utf-8",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
# Spool loading helper
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
def _load_spool_sessions():
|
|
441
|
+
"""Scan SPOOL_DIR for valid spool exports, return (row_key, filename, data) tuples.
|
|
442
|
+
|
|
443
|
+
Computes canonical row_key from file content, not filename.
|
|
444
|
+
Skips files that fail validation.
|
|
445
|
+
"""
|
|
446
|
+
results = []
|
|
447
|
+
if not SPOOL_DIR.is_dir():
|
|
448
|
+
return results
|
|
449
|
+
|
|
450
|
+
for p in sorted(SPOOL_DIR.glob("*.json")):
|
|
451
|
+
if p.name == ".exported.json":
|
|
452
|
+
continue
|
|
453
|
+
try:
|
|
454
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
455
|
+
except (json.JSONDecodeError, OSError):
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
if data.get("schema") != "gator-session-export-v1":
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
metadata = data.get("metadata", {})
|
|
462
|
+
session_id = metadata.get("session_id")
|
|
463
|
+
if not session_id:
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
source_path = metadata.get("source_path", "") or ""
|
|
467
|
+
row_key = _spool_slug(session_id, source_path)
|
|
468
|
+
results.append((row_key, p.name, data))
|
|
469
|
+
|
|
470
|
+
return results
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
# Command: turn-manifest
|
|
475
|
+
# ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def cmd_turn_manifest(args):
|
|
478
|
+
"""Generate turn manifests for spool exports."""
|
|
479
|
+
spool_sessions = _load_spool_sessions()
|
|
480
|
+
|
|
481
|
+
if not spool_sessions:
|
|
482
|
+
print(" No spool exports found.")
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
MANIFEST_DIR.mkdir(parents=True, exist_ok=True)
|
|
486
|
+
count = 0
|
|
487
|
+
|
|
488
|
+
for row_key, spool_filename, data in spool_sessions:
|
|
489
|
+
manifest_path = MANIFEST_DIR / f"{row_key}.turns.json"
|
|
490
|
+
|
|
491
|
+
# Filter by row-key
|
|
492
|
+
if args.row_key and row_key != args.row_key:
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
# Skip already-generated unless --force
|
|
496
|
+
if manifest_path.exists() and not args.force:
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
manifest = build_turn_manifest(data)
|
|
500
|
+
manifest["spool_file"] = spool_filename
|
|
501
|
+
write_turn_manifest(manifest, manifest_path)
|
|
502
|
+
count += 1
|
|
503
|
+
print(f" ✓ {row_key[:8]} ({manifest['turn_count']} turns) → {manifest_path.name}")
|
|
504
|
+
|
|
505
|
+
print()
|
|
506
|
+
print(f" Generated {count} turn manifests in {MANIFEST_DIR}")
|
|
507
|
+
print()
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
# Command: grep-turns
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
def cmd_grep_turns(args):
|
|
515
|
+
"""Search turn manifests by file, function, keyword, vendor, repo, role."""
|
|
516
|
+
if not MANIFEST_DIR.is_dir():
|
|
517
|
+
print(" No turn manifests found. Run 'turn-manifest' first.")
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
matches = []
|
|
521
|
+
|
|
522
|
+
for p in sorted(MANIFEST_DIR.glob("*.turns.json")):
|
|
523
|
+
try:
|
|
524
|
+
manifest = json.loads(p.read_text(encoding="utf-8"))
|
|
525
|
+
except (json.JSONDecodeError, OSError):
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
# Session-level filters
|
|
529
|
+
if args.vendor and manifest.get("vendor") != args.vendor:
|
|
530
|
+
continue
|
|
531
|
+
if args.repo and manifest.get("repo") != args.repo:
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
row_key = manifest.get("row_key", "")
|
|
535
|
+
|
|
536
|
+
for turn in manifest.get("turns", []):
|
|
537
|
+
# Turn-level filters (all must match)
|
|
538
|
+
if args.file:
|
|
539
|
+
import fnmatch
|
|
540
|
+
if not any(fnmatch.fnmatch(f, args.file) for f in turn.get("mentions_files", [])):
|
|
541
|
+
continue
|
|
542
|
+
if args.function:
|
|
543
|
+
if not any(args.function in fn for fn in turn.get("mentions_functions", [])):
|
|
544
|
+
continue
|
|
545
|
+
if args.keyword:
|
|
546
|
+
if args.keyword.lower() not in turn.get("keywords", []):
|
|
547
|
+
continue
|
|
548
|
+
if args.role:
|
|
549
|
+
if turn.get("role") != args.role:
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
matches.append({
|
|
553
|
+
"row_key": row_key,
|
|
554
|
+
"seq": turn.get("seq", 0),
|
|
555
|
+
"role": turn.get("role", ""),
|
|
556
|
+
"preview": turn.get("preview", ""),
|
|
557
|
+
"vendor": manifest.get("vendor", ""),
|
|
558
|
+
"repo": manifest.get("repo", ""),
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
if args.json:
|
|
562
|
+
print(json.dumps({"matches": matches, "count": len(matches)}, indent=2))
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
if not matches:
|
|
566
|
+
print(" No matching turns found.")
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
print()
|
|
570
|
+
print(f" {len(matches)} matching turns")
|
|
571
|
+
print()
|
|
572
|
+
for m in matches:
|
|
573
|
+
preview = m["preview"][:80]
|
|
574
|
+
print(f" {m['row_key'][:8]} seq={m['seq']:<3} {m['role']:<10} {preview}")
|
|
575
|
+
print()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ---------------------------------------------------------------------------
|
|
579
|
+
# Command: show-turns
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
def cmd_show_turns(args):
|
|
583
|
+
"""Show full turn content from a spool export by sequence number."""
|
|
584
|
+
row_key = args.row_key
|
|
585
|
+
|
|
586
|
+
# Try to find spool file via manifest first (fast path)
|
|
587
|
+
spool_data = None
|
|
588
|
+
manifest_path = MANIFEST_DIR / f"{row_key}.turns.json"
|
|
589
|
+
if manifest_path.exists():
|
|
590
|
+
try:
|
|
591
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
592
|
+
spool_name = manifest.get("spool_file", "")
|
|
593
|
+
if spool_name:
|
|
594
|
+
spool_path = SPOOL_DIR / spool_name
|
|
595
|
+
if spool_path.exists():
|
|
596
|
+
spool_data = json.loads(spool_path.read_text(encoding="utf-8"))
|
|
597
|
+
except (json.JSONDecodeError, OSError):
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
# Fallback: scan spool dir
|
|
601
|
+
if spool_data is None:
|
|
602
|
+
for rk, fname, data in _load_spool_sessions():
|
|
603
|
+
if rk == row_key:
|
|
604
|
+
spool_data = data
|
|
605
|
+
break
|
|
606
|
+
|
|
607
|
+
if spool_data is None:
|
|
608
|
+
print(f" No spool export found for row_key {row_key}")
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
turns = spool_data.get("turns", [])
|
|
612
|
+
# Parse --seq
|
|
613
|
+
requested = set()
|
|
614
|
+
for part in args.seq.split(","):
|
|
615
|
+
part = part.strip()
|
|
616
|
+
if part:
|
|
617
|
+
try:
|
|
618
|
+
requested.add(int(part))
|
|
619
|
+
except ValueError:
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
# Expand with --context
|
|
623
|
+
context_n = args.context or 0
|
|
624
|
+
expanded = set()
|
|
625
|
+
for seq in requested:
|
|
626
|
+
for offset in range(-context_n, context_n + 1):
|
|
627
|
+
expanded.add(seq + offset)
|
|
628
|
+
|
|
629
|
+
# Print matching turns
|
|
630
|
+
for i, turn in enumerate(turns):
|
|
631
|
+
seq = turn.get("seq", i + 1)
|
|
632
|
+
if seq not in expanded:
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
role = turn.get("role", "unknown")
|
|
636
|
+
ts = turn.get("timestamp", "")
|
|
637
|
+
text = extract_turn_text(turn)
|
|
638
|
+
|
|
639
|
+
print()
|
|
640
|
+
print(f" ── turn {seq} ({role}) {ts} ──")
|
|
641
|
+
print()
|
|
642
|
+
# Indent content
|
|
643
|
+
for line in text.splitlines():
|
|
644
|
+
print(f" {line}")
|
|
645
|
+
print()
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# ---------------------------------------------------------------------------
|
|
649
|
+
# Command: index
|
|
650
|
+
# ---------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
def cmd_index(args):
|
|
653
|
+
"""Show all discoverable sessions across vendors."""
|
|
654
|
+
sessions = discover_all_sessions()
|
|
655
|
+
|
|
656
|
+
if args.since:
|
|
657
|
+
since_dt = parse_since(args.since)
|
|
658
|
+
sessions = filter_sessions_since(sessions, since_dt)
|
|
659
|
+
|
|
660
|
+
if args.json:
|
|
661
|
+
machine = get_machine_identity()
|
|
662
|
+
output = {
|
|
663
|
+
"schema": "gator-session-index-v1",
|
|
664
|
+
"machine": machine,
|
|
665
|
+
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
666
|
+
"sessions": [
|
|
667
|
+
{**s, "row_key": _spool_slug(s["session_id"], s.get("path", ""))}
|
|
668
|
+
for s in sessions
|
|
669
|
+
],
|
|
670
|
+
"summary": {
|
|
671
|
+
"total": len(sessions),
|
|
672
|
+
"by_vendor": {},
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
for s in sessions:
|
|
676
|
+
v = s["vendor"]
|
|
677
|
+
output["summary"]["by_vendor"][v] = output["summary"]["by_vendor"].get(v, 0) + 1
|
|
678
|
+
print(json.dumps(output, indent=2, default=str))
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
# Text output
|
|
682
|
+
print()
|
|
683
|
+
print(" gator sessions index")
|
|
684
|
+
print(f" {len(sessions)} sessions discovered")
|
|
685
|
+
print()
|
|
686
|
+
|
|
687
|
+
by_vendor = {}
|
|
688
|
+
for s in sessions:
|
|
689
|
+
by_vendor.setdefault(s["vendor"], []).append(s)
|
|
690
|
+
|
|
691
|
+
for vendor in sorted(by_vendor.keys()):
|
|
692
|
+
items = by_vendor[vendor]
|
|
693
|
+
print(f" {vendor} ({len(items)} sessions)")
|
|
694
|
+
for s in items[:10]: # Show latest 10 per vendor
|
|
695
|
+
project = s["project"]
|
|
696
|
+
size_kb = s["size"] // 1024
|
|
697
|
+
proj_str = f" [{project}]" if project else ""
|
|
698
|
+
print(f" {s['session_id'][:8]} {s['date']} {size_kb:>4} KB{proj_str} {s['modified']}")
|
|
699
|
+
if len(items) > 10:
|
|
700
|
+
print(f" ... and {len(items) - 10} more")
|
|
701
|
+
print()
|
|
702
|
+
|
|
703
|
+
exported = load_exported_state()
|
|
704
|
+
pending = len([s for s in sessions if f"{s['vendor']}-{_spool_slug(s['session_id'], s.get('path', ''))}" not in exported])
|
|
705
|
+
print(f" pending export: {pending} sessions")
|
|
706
|
+
print()
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ---------------------------------------------------------------------------
|
|
710
|
+
# Command: manifest
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
def cmd_manifest(args):
|
|
714
|
+
"""Emit JSON manifest for enterprise automation."""
|
|
715
|
+
sessions = discover_all_sessions()
|
|
716
|
+
|
|
717
|
+
if args.since:
|
|
718
|
+
since_dt = parse_since(args.since)
|
|
719
|
+
sessions = filter_sessions_since(sessions, since_dt)
|
|
720
|
+
|
|
721
|
+
if args.pending:
|
|
722
|
+
sessions = get_pending_sessions(sessions)
|
|
723
|
+
|
|
724
|
+
machine = get_machine_identity()
|
|
725
|
+
|
|
726
|
+
manifest = {
|
|
727
|
+
"schema": "gator-session-manifest-v1",
|
|
728
|
+
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
729
|
+
"machine": {
|
|
730
|
+
"id": machine.get("id", ""),
|
|
731
|
+
"hostname": machine.get("hostname", ""),
|
|
732
|
+
"label": machine.get("label", ""),
|
|
733
|
+
},
|
|
734
|
+
"sessions": [],
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
for s in sessions:
|
|
738
|
+
spool_path = SPOOL_DIR / f"{s['vendor']}-{_spool_slug(s['session_id'], s.get('path', ''))}.json"
|
|
739
|
+
row_key = _spool_slug(s["session_id"], s.get("path", ""))
|
|
740
|
+
manifest["sessions"].append({
|
|
741
|
+
"row_key": row_key,
|
|
742
|
+
"session_id": s["session_id"],
|
|
743
|
+
"vendor": s["vendor"],
|
|
744
|
+
"project": s["project"],
|
|
745
|
+
"date": s["date"],
|
|
746
|
+
"modified": s["modified"],
|
|
747
|
+
"size": s["size"],
|
|
748
|
+
"source_path": s["path"],
|
|
749
|
+
"spool_path": str(spool_path) if spool_path.exists() else None,
|
|
750
|
+
"status": "exported" if spool_path.exists() else "pending",
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
print(json.dumps(manifest, indent=2, default=str))
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# ---------------------------------------------------------------------------
|
|
757
|
+
# Command: export
|
|
758
|
+
# ---------------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
def cmd_export(args):
|
|
761
|
+
"""Write normalized session exports to spool directory."""
|
|
762
|
+
sessions = discover_all_sessions()
|
|
763
|
+
|
|
764
|
+
if args.since:
|
|
765
|
+
since_dt = parse_since(args.since)
|
|
766
|
+
sessions = filter_sessions_since(sessions, since_dt)
|
|
767
|
+
|
|
768
|
+
if args.pending:
|
|
769
|
+
sessions = get_pending_sessions(sessions)
|
|
770
|
+
|
|
771
|
+
if not sessions:
|
|
772
|
+
print(" No sessions to export.")
|
|
773
|
+
return
|
|
774
|
+
|
|
775
|
+
SPOOL_DIR.mkdir(parents=True, exist_ok=True)
|
|
776
|
+
machine = get_machine_identity()
|
|
777
|
+
exported = load_exported_state()
|
|
778
|
+
count = 0
|
|
779
|
+
|
|
780
|
+
for s in sessions:
|
|
781
|
+
vendor = s["vendor"]
|
|
782
|
+
session_id = s["session_id"]
|
|
783
|
+
source_path = s.get("path", "")
|
|
784
|
+
spool_file = SPOOL_DIR / f"{vendor}-{_spool_slug(session_id, source_path)}.json"
|
|
785
|
+
|
|
786
|
+
# Extract using vendor-specific script
|
|
787
|
+
turns = []
|
|
788
|
+
metadata = {}
|
|
789
|
+
summary = {}
|
|
790
|
+
|
|
791
|
+
try:
|
|
792
|
+
if vendor == "claude":
|
|
793
|
+
mod = _import_vendor("extract-claude-sessions")
|
|
794
|
+
if mod:
|
|
795
|
+
raw_turns = mod.extract_session(Path(s["path"]))
|
|
796
|
+
meta = mod.extract_session_metadata(raw_turns)
|
|
797
|
+
summ = mod.format_session_summary(raw_turns, meta)
|
|
798
|
+
turns = raw_turns
|
|
799
|
+
metadata = meta
|
|
800
|
+
summary = summ
|
|
801
|
+
|
|
802
|
+
elif vendor == "codex":
|
|
803
|
+
mod = _import_vendor("extract-codex-sessions")
|
|
804
|
+
if mod:
|
|
805
|
+
raw_turns, session_meta = mod.extract_session(Path(s["path"]))
|
|
806
|
+
meta = mod.extract_session_metadata(raw_turns, session_meta)
|
|
807
|
+
summ = mod.format_session_summary(raw_turns, meta)
|
|
808
|
+
turns = raw_turns
|
|
809
|
+
metadata = meta
|
|
810
|
+
summary = summ
|
|
811
|
+
|
|
812
|
+
elif vendor == "gemini":
|
|
813
|
+
mod = _import_vendor("extract-gemini-sessions")
|
|
814
|
+
if mod:
|
|
815
|
+
raw_turns, session_meta = mod.extract_session(Path(s["path"]))
|
|
816
|
+
meta = mod.extract_session_metadata(raw_turns, session_meta, s["project"])
|
|
817
|
+
summ = mod.format_session_summary(raw_turns, meta)
|
|
818
|
+
turns = raw_turns
|
|
819
|
+
metadata = meta
|
|
820
|
+
summary = summ
|
|
821
|
+
|
|
822
|
+
except Exception as e:
|
|
823
|
+
print(f" ! Error extracting {vendor}/{session_id[:8]}: {e}")
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
if not turns:
|
|
827
|
+
continue
|
|
828
|
+
|
|
829
|
+
# Inject source_path for row_key / transcript path generation
|
|
830
|
+
metadata["source_path"] = source_path
|
|
831
|
+
|
|
832
|
+
# Apply redaction
|
|
833
|
+
try:
|
|
834
|
+
common = _import_vendor("gator-session-common")
|
|
835
|
+
if common:
|
|
836
|
+
redacted_turns = []
|
|
837
|
+
for t in turns:
|
|
838
|
+
t_copy = dict(t)
|
|
839
|
+
t_copy["content"] = common.redact(t_copy.get("content", ""))
|
|
840
|
+
redacted_turns.append(t_copy)
|
|
841
|
+
turns = redacted_turns
|
|
842
|
+
|
|
843
|
+
# Redact summary fields (goal, decision texts)
|
|
844
|
+
if summary:
|
|
845
|
+
if "goal" in summary:
|
|
846
|
+
summary["goal"] = common.redact(summary["goal"], summary_mode=True)
|
|
847
|
+
if "decisions" in summary:
|
|
848
|
+
for d in summary["decisions"]:
|
|
849
|
+
if "text" in d:
|
|
850
|
+
d["text"] = common.redact(d["text"], summary_mode=True)
|
|
851
|
+
except Exception:
|
|
852
|
+
pass # Proceed without redaction if module unavailable
|
|
853
|
+
|
|
854
|
+
# Write normalized export
|
|
855
|
+
export_data = {
|
|
856
|
+
"schema": "gator-session-export-v1",
|
|
857
|
+
"exported_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
858
|
+
"machine": {
|
|
859
|
+
"id": machine.get("id", ""),
|
|
860
|
+
"hostname": machine.get("hostname", ""),
|
|
861
|
+
"label": machine.get("label", ""),
|
|
862
|
+
},
|
|
863
|
+
"vendor": vendor,
|
|
864
|
+
"metadata": metadata,
|
|
865
|
+
"summary": summary,
|
|
866
|
+
"turns": turns,
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
spool_file.write_text(
|
|
870
|
+
json.dumps(export_data, indent=2, default=str) + "\n",
|
|
871
|
+
encoding="utf-8",
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
exported.add(f"{vendor}-{_spool_slug(session_id, source_path)}")
|
|
875
|
+
count += 1
|
|
876
|
+
print(f" ✓ {vendor}/{session_id[:8]} → {spool_file.name}")
|
|
877
|
+
|
|
878
|
+
save_exported_state(exported)
|
|
879
|
+
print()
|
|
880
|
+
print(f" Exported {count} sessions to {SPOOL_DIR}")
|
|
881
|
+
print()
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
# ---------------------------------------------------------------------------
|
|
885
|
+
# Command: pending
|
|
886
|
+
# ---------------------------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
def cmd_pending(args):
|
|
889
|
+
"""Show sessions not yet exported to spool."""
|
|
890
|
+
sessions = discover_all_sessions()
|
|
891
|
+
pending = get_pending_sessions(sessions)
|
|
892
|
+
|
|
893
|
+
if args.json:
|
|
894
|
+
print(json.dumps({
|
|
895
|
+
"pending": len(pending),
|
|
896
|
+
"total": len(sessions),
|
|
897
|
+
"sessions": pending,
|
|
898
|
+
}, indent=2, default=str))
|
|
899
|
+
return
|
|
900
|
+
|
|
901
|
+
print()
|
|
902
|
+
print(f" gator sessions pending")
|
|
903
|
+
print(f" {len(pending)} of {len(sessions)} sessions not yet exported")
|
|
904
|
+
print()
|
|
905
|
+
|
|
906
|
+
for s in pending[:20]:
|
|
907
|
+
proj_str = f" [{s['project']}]" if s["project"] else ""
|
|
908
|
+
print(f" {s['vendor']:<8} {s['session_id'][:8]} {s['date']}{proj_str}")
|
|
909
|
+
|
|
910
|
+
if len(pending) > 20:
|
|
911
|
+
print(f" ... and {len(pending) - 20} more")
|
|
912
|
+
print()
|
|
913
|
+
|
|
914
|
+
if pending:
|
|
915
|
+
print(f" Run 'gator sessions export --pending' to export them.")
|
|
916
|
+
print()
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
# ---------------------------------------------------------------------------
|
|
920
|
+
# Entry point
|
|
921
|
+
# ---------------------------------------------------------------------------
|
|
922
|
+
|
|
923
|
+
# ---------------------------------------------------------------------------
|
|
924
|
+
# Command: commit-summaries
|
|
925
|
+
# ---------------------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
def cmd_commit_summaries(args):
|
|
928
|
+
"""Write git-tracked session summaries to .gator/sessions/.
|
|
929
|
+
|
|
930
|
+
This is the committed summary layer — the durable, portable,
|
|
931
|
+
signed audit trail. Unlike the spool (local, ephemeral), these
|
|
932
|
+
summaries travel with the repo via git.
|
|
933
|
+
|
|
934
|
+
The audit dashboard reads from these instead of raw vendor logs,
|
|
935
|
+
making it work on any machine with the git repo.
|
|
936
|
+
"""
|
|
937
|
+
# Find target directory
|
|
938
|
+
if args.path:
|
|
939
|
+
sessions_dir = Path(args.path)
|
|
940
|
+
else:
|
|
941
|
+
# Default: .gator/sessions/ in the current repo
|
|
942
|
+
gator_dir = Path.cwd() / ".gator"
|
|
943
|
+
if not gator_dir.is_dir():
|
|
944
|
+
# Try command post
|
|
945
|
+
for candidate in [Path.cwd(), Path.cwd().parent]:
|
|
946
|
+
if (candidate / "gator-command").is_dir():
|
|
947
|
+
gator_dir = candidate / ".gator"
|
|
948
|
+
break
|
|
949
|
+
sessions_dir = gator_dir / "sessions"
|
|
950
|
+
|
|
951
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
952
|
+
|
|
953
|
+
# Discover sessions
|
|
954
|
+
all_sessions = discover_all_sessions()
|
|
955
|
+
|
|
956
|
+
if args.since:
|
|
957
|
+
since_dt = parse_since(args.since)
|
|
958
|
+
all_sessions = filter_sessions_since(all_sessions, since_dt)
|
|
959
|
+
|
|
960
|
+
if not all_sessions:
|
|
961
|
+
print(" No sessions to commit.")
|
|
962
|
+
return
|
|
963
|
+
|
|
964
|
+
# Track what's already committed (by filename)
|
|
965
|
+
existing = {f.name for f in sessions_dir.iterdir() if f.suffix == ".md"}
|
|
966
|
+
|
|
967
|
+
common = _import_vendor("gator-session-common")
|
|
968
|
+
if not common:
|
|
969
|
+
print(" Error: gator-session-common.py not available.", file=sys.stderr)
|
|
970
|
+
return
|
|
971
|
+
|
|
972
|
+
written = 0
|
|
973
|
+
skipped = 0
|
|
974
|
+
|
|
975
|
+
for s in all_sessions:
|
|
976
|
+
vendor = s["vendor"]
|
|
977
|
+
session_id = s["session_id"]
|
|
978
|
+
source_path = s.get("path", "")
|
|
979
|
+
|
|
980
|
+
# Generate deterministic filename
|
|
981
|
+
row_key = common.make_row_key({
|
|
982
|
+
"session_id": session_id,
|
|
983
|
+
"source_path": source_path,
|
|
984
|
+
})
|
|
985
|
+
date_str = s.get("date", "unknown")
|
|
986
|
+
project = s.get("project", "unknown") or "unknown"
|
|
987
|
+
filename = f"{date_str}-{project}-{vendor}-{row_key}.md"
|
|
988
|
+
|
|
989
|
+
if filename in existing and not args.force:
|
|
990
|
+
skipped += 1
|
|
991
|
+
continue
|
|
992
|
+
|
|
993
|
+
# Extract and format summary
|
|
994
|
+
try:
|
|
995
|
+
turns = []
|
|
996
|
+
metadata = {}
|
|
997
|
+
|
|
998
|
+
if vendor == "claude":
|
|
999
|
+
mod = _import_vendor("extract-claude-sessions")
|
|
1000
|
+
if mod:
|
|
1001
|
+
turns = mod.extract_session(Path(source_path))
|
|
1002
|
+
metadata = mod.extract_session_metadata(turns)
|
|
1003
|
+
|
|
1004
|
+
elif vendor == "codex":
|
|
1005
|
+
mod = _import_vendor("extract-codex-sessions")
|
|
1006
|
+
if mod:
|
|
1007
|
+
turns, session_meta = mod.extract_session(Path(source_path))
|
|
1008
|
+
metadata = mod.extract_session_metadata(turns, session_meta)
|
|
1009
|
+
|
|
1010
|
+
elif vendor == "gemini":
|
|
1011
|
+
mod = _import_vendor("extract-gemini-sessions")
|
|
1012
|
+
if mod:
|
|
1013
|
+
turns, session_meta = mod.extract_session(Path(source_path))
|
|
1014
|
+
metadata = mod.extract_session_metadata(turns, session_meta, project)
|
|
1015
|
+
|
|
1016
|
+
if not turns:
|
|
1017
|
+
continue
|
|
1018
|
+
|
|
1019
|
+
# Inject source path for row_key generation
|
|
1020
|
+
metadata["source_path"] = source_path
|
|
1021
|
+
|
|
1022
|
+
# Generate summary markdown using the canonical formatter
|
|
1023
|
+
summary_md = common.format_summary_markdown(turns, metadata)
|
|
1024
|
+
|
|
1025
|
+
# Write to .gator/sessions/
|
|
1026
|
+
summary_file = sessions_dir / filename
|
|
1027
|
+
summary_file.write_text(summary_md, encoding="utf-8")
|
|
1028
|
+
written += 1
|
|
1029
|
+
|
|
1030
|
+
if not args.quiet:
|
|
1031
|
+
print(f" ✓ {filename}")
|
|
1032
|
+
|
|
1033
|
+
except (OSError, KeyError, ValueError, UnicodeDecodeError) as e:
|
|
1034
|
+
print(f" ! {vendor}/{session_id[:8]}: {type(e).__name__}: {e}")
|
|
1035
|
+
continue
|
|
1036
|
+
|
|
1037
|
+
print()
|
|
1038
|
+
print(f" Committed: {written} summaries to {sessions_dir}")
|
|
1039
|
+
if skipped:
|
|
1040
|
+
print(f" Skipped: {skipped} (already exist, use --force to overwrite)")
|
|
1041
|
+
print()
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def parse_committed_summary(text, filename=""):
|
|
1045
|
+
"""Parse a single committed summary markdown into a structured dict.
|
|
1046
|
+
|
|
1047
|
+
Handles two schema types:
|
|
1048
|
+
- gator-session-summary-v1: from archaeology (gator sessions commit-summaries)
|
|
1049
|
+
- gator-commit-summary-v1: from pre-commit hook (structural, automatic)
|
|
1050
|
+
|
|
1051
|
+
Returns a dict with: date, repo, vendor, decisions, goal, source_file, start.
|
|
1052
|
+
Returns None if the text has no parseable frontmatter.
|
|
1053
|
+
|
|
1054
|
+
This is the canonical parser for committed summaries. Used by both
|
|
1055
|
+
the local read path (read_committed_summaries) and the remote read
|
|
1056
|
+
path (gator_remote + gator-audit.py). Do not duplicate this logic.
|
|
1057
|
+
"""
|
|
1058
|
+
import re
|
|
1059
|
+
|
|
1060
|
+
meta = {}
|
|
1061
|
+
decisions = []
|
|
1062
|
+
goal_lines = []
|
|
1063
|
+
in_frontmatter = False
|
|
1064
|
+
in_decisions = False
|
|
1065
|
+
in_goal = False
|
|
1066
|
+
|
|
1067
|
+
for line in text.splitlines():
|
|
1068
|
+
stripped = line.strip()
|
|
1069
|
+
|
|
1070
|
+
if stripped == "---":
|
|
1071
|
+
if not in_frontmatter:
|
|
1072
|
+
in_frontmatter = True
|
|
1073
|
+
continue
|
|
1074
|
+
else:
|
|
1075
|
+
in_frontmatter = False
|
|
1076
|
+
continue
|
|
1077
|
+
|
|
1078
|
+
if in_frontmatter and ":" in stripped:
|
|
1079
|
+
key, _, value = stripped.partition(":")
|
|
1080
|
+
meta[key.strip()] = value.strip()
|
|
1081
|
+
continue
|
|
1082
|
+
|
|
1083
|
+
if stripped == "## Goal":
|
|
1084
|
+
in_goal = True
|
|
1085
|
+
in_decisions = False
|
|
1086
|
+
continue
|
|
1087
|
+
elif stripped == "## Decisions":
|
|
1088
|
+
in_goal = False
|
|
1089
|
+
in_decisions = True
|
|
1090
|
+
continue
|
|
1091
|
+
elif stripped.startswith("## "):
|
|
1092
|
+
in_goal = False
|
|
1093
|
+
in_decisions = False
|
|
1094
|
+
continue
|
|
1095
|
+
|
|
1096
|
+
if in_goal and stripped and stripped != "*No goal extracted*":
|
|
1097
|
+
goal_lines.append(stripped)
|
|
1098
|
+
|
|
1099
|
+
if in_decisions and stripped.startswith("- "):
|
|
1100
|
+
# Parse decision: - [timestamp] text
|
|
1101
|
+
m = re.match(r'^- \[([^\]]*)\]\s*(.*)', stripped)
|
|
1102
|
+
if m:
|
|
1103
|
+
decisions.append({
|
|
1104
|
+
"timestamp": m.group(1),
|
|
1105
|
+
"text": m.group(2),
|
|
1106
|
+
})
|
|
1107
|
+
elif stripped != "- *No decisions extracted*":
|
|
1108
|
+
decisions.append({
|
|
1109
|
+
"timestamp": meta.get("date", ""),
|
|
1110
|
+
"text": stripped[2:],
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
if not meta:
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
"date": meta.get("date", ""),
|
|
1118
|
+
"repo": meta.get("repo", ""),
|
|
1119
|
+
"vendor": meta.get("vendor", ""),
|
|
1120
|
+
"agent": meta.get("agent", ""),
|
|
1121
|
+
"goal": " ".join(goal_lines),
|
|
1122
|
+
"decisions": decisions,
|
|
1123
|
+
"source_file": filename,
|
|
1124
|
+
"start": meta.get("start", meta.get("timestamp", "")),
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def read_committed_summaries(sessions_dir, since_days=7):
|
|
1129
|
+
"""Read committed summaries from .gator/sessions/.
|
|
1130
|
+
|
|
1131
|
+
Returns a list of dicts with: date, repo, vendor, decisions, goal.
|
|
1132
|
+
This is the durable read path for gator audit — no vendor parsers,
|
|
1133
|
+
no local tool storage, just git-tracked markdown.
|
|
1134
|
+
"""
|
|
1135
|
+
if not sessions_dir.is_dir():
|
|
1136
|
+
return []
|
|
1137
|
+
|
|
1138
|
+
from datetime import datetime, timedelta, timezone
|
|
1139
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=since_days)).strftime("%Y-%m-%d")
|
|
1140
|
+
|
|
1141
|
+
summaries = []
|
|
1142
|
+
for f in sorted(sessions_dir.iterdir(), reverse=True):
|
|
1143
|
+
if not f.suffix == ".md":
|
|
1144
|
+
continue
|
|
1145
|
+
|
|
1146
|
+
# Quick date filter from filename (YYYY-MM-DD-repo-vendor-hash.md)
|
|
1147
|
+
name = f.name
|
|
1148
|
+
if len(name) >= 10 and name[:10] < cutoff:
|
|
1149
|
+
continue
|
|
1150
|
+
|
|
1151
|
+
try:
|
|
1152
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
1153
|
+
except OSError:
|
|
1154
|
+
continue
|
|
1155
|
+
|
|
1156
|
+
result = parse_committed_summary(text, f.name)
|
|
1157
|
+
if result:
|
|
1158
|
+
summaries.append(result)
|
|
1159
|
+
|
|
1160
|
+
return summaries
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def main():
|
|
1164
|
+
if sys.stdout.encoding != "utf-8":
|
|
1165
|
+
sys.stdout = io.TextIOWrapper(
|
|
1166
|
+
sys.stdout.buffer, encoding="utf-8", errors="replace"
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
parser = argparse.ArgumentParser(
|
|
1170
|
+
description="Gator sessions — audit infrastructure for AI coding sessions."
|
|
1171
|
+
)
|
|
1172
|
+
sub = parser.add_subparsers(dest="command", help="Command")
|
|
1173
|
+
|
|
1174
|
+
# index
|
|
1175
|
+
idx = sub.add_parser("index", help="Show all discoverable sessions")
|
|
1176
|
+
idx.add_argument("--json", "-j", action="store_true")
|
|
1177
|
+
idx.add_argument("--since", help="Filter by recency (e.g., 24h, 7d, 1w)")
|
|
1178
|
+
|
|
1179
|
+
# manifest
|
|
1180
|
+
mfst = sub.add_parser("manifest", help="Emit JSON manifest for automation")
|
|
1181
|
+
mfst.add_argument("--since", help="Filter by recency")
|
|
1182
|
+
mfst.add_argument("--pending", action="store_true", help="Only pending sessions")
|
|
1183
|
+
|
|
1184
|
+
# export
|
|
1185
|
+
exp = sub.add_parser("export", help="Export sessions to spool directory")
|
|
1186
|
+
exp.add_argument("--since", help="Filter by recency")
|
|
1187
|
+
exp.add_argument("--pending", action="store_true", help="Only pending sessions")
|
|
1188
|
+
|
|
1189
|
+
# pending
|
|
1190
|
+
pnd = sub.add_parser("pending", help="Show sessions not yet exported")
|
|
1191
|
+
pnd.add_argument("--json", "-j", action="store_true")
|
|
1192
|
+
|
|
1193
|
+
# commit-summaries
|
|
1194
|
+
cs = sub.add_parser("commit-summaries", help="Write git-tracked summaries to .gator/sessions/")
|
|
1195
|
+
cs.add_argument("--since", help="Filter by recency (e.g., 7d, 30d)")
|
|
1196
|
+
cs.add_argument("--path", help="Target directory (default: .gator/sessions/)")
|
|
1197
|
+
cs.add_argument("--force", action="store_true", help="Overwrite existing summaries")
|
|
1198
|
+
cs.add_argument("--quiet", "-q", action="store_true", help="Suppress per-file output")
|
|
1199
|
+
|
|
1200
|
+
# turn-manifest
|
|
1201
|
+
tm = sub.add_parser("turn-manifest", help="Generate turn manifests from spool exports")
|
|
1202
|
+
tm.add_argument("--row-key", help="Generate for a specific row key")
|
|
1203
|
+
tm.add_argument("--force", action="store_true", help="Overwrite existing manifests")
|
|
1204
|
+
|
|
1205
|
+
# grep-turns
|
|
1206
|
+
gt = sub.add_parser("grep-turns", help="Search turn manifests")
|
|
1207
|
+
gt.add_argument("--file", help="Match file mentions (fnmatch pattern)")
|
|
1208
|
+
gt.add_argument("--function", help="Match function mentions (substring)")
|
|
1209
|
+
gt.add_argument("--keyword", help="Match keywords (exact)")
|
|
1210
|
+
gt.add_argument("--vendor", help="Filter by vendor")
|
|
1211
|
+
gt.add_argument("--repo", help="Filter by repo")
|
|
1212
|
+
gt.add_argument("--role", help="Filter by role")
|
|
1213
|
+
gt.add_argument("--json", "-j", action="store_true", help="JSON output")
|
|
1214
|
+
|
|
1215
|
+
# show-turns
|
|
1216
|
+
st = sub.add_parser("show-turns", help="Show full turn content from spool exports")
|
|
1217
|
+
st.add_argument("--row-key", required=True, help="Session row key")
|
|
1218
|
+
st.add_argument("--seq", required=True, help="Turn sequence numbers (comma-separated)")
|
|
1219
|
+
st.add_argument("--context", type=int, default=0, help="Number of surrounding turns to show")
|
|
1220
|
+
|
|
1221
|
+
args = parser.parse_args()
|
|
1222
|
+
|
|
1223
|
+
if args.command == "index":
|
|
1224
|
+
cmd_index(args)
|
|
1225
|
+
elif args.command == "manifest":
|
|
1226
|
+
cmd_manifest(args)
|
|
1227
|
+
elif args.command == "export":
|
|
1228
|
+
cmd_export(args)
|
|
1229
|
+
elif args.command == "pending":
|
|
1230
|
+
cmd_pending(args)
|
|
1231
|
+
elif args.command == "commit-summaries":
|
|
1232
|
+
cmd_commit_summaries(args)
|
|
1233
|
+
elif args.command == "turn-manifest":
|
|
1234
|
+
cmd_turn_manifest(args)
|
|
1235
|
+
elif args.command == "grep-turns":
|
|
1236
|
+
cmd_grep_turns(args)
|
|
1237
|
+
elif args.command == "show-turns":
|
|
1238
|
+
cmd_show_turns(args)
|
|
1239
|
+
else:
|
|
1240
|
+
parser.print_help()
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
if __name__ == "__main__":
|
|
1244
|
+
main()
|