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.
Files changed (110) hide show
  1. gator_command/__init__.py +2 -0
  2. gator_command/cli.py +137 -0
  3. gator_command/scripts/crawler.py +633 -0
  4. gator_command/scripts/dashboard/dashboard.css +982 -0
  5. gator_command/scripts/dashboard/dashboard.html +84 -0
  6. gator_command/scripts/dashboard/dashboard.js +419 -0
  7. gator_command/scripts/dashboard/views/audit.js +270 -0
  8. gator_command/scripts/dashboard/views/fleet.js +307 -0
  9. gator_command/scripts/dashboard/views/repo.js +599 -0
  10. gator_command/scripts/dashboard/views/settings.js +173 -0
  11. gator_command/scripts/dashboard/views/updates.js +308 -0
  12. gator_command/scripts/enforcer-prompt.md +22 -0
  13. gator_command/scripts/extract-claude-sessions.py +489 -0
  14. gator_command/scripts/extract-codex-sessions.py +477 -0
  15. gator_command/scripts/extract-gemini-sessions.py +410 -0
  16. gator_command/scripts/gator-audit.py +956 -0
  17. gator_command/scripts/gator-charter-draft.py +919 -0
  18. gator_command/scripts/gator-charter-lint.py +427 -0
  19. gator_command/scripts/gator-charter-verify.py +606 -0
  20. gator_command/scripts/gator-dashboard.py +1271 -0
  21. gator_command/scripts/gator-deploy.py +916 -0
  22. gator_command/scripts/gator-drift.py +569 -0
  23. gator_command/scripts/gator-enforce.py +82 -0
  24. gator_command/scripts/gator-fleet-intel.py +460 -0
  25. gator_command/scripts/gator-fleet-report.py +615 -0
  26. gator_command/scripts/gator-init-command-post.py +315 -0
  27. gator_command/scripts/gator-init.py +434 -0
  28. gator_command/scripts/gator-machine-id.py +153 -0
  29. gator_command/scripts/gator-policy-status.py +631 -0
  30. gator_command/scripts/gator-pulse.py +459 -0
  31. gator_command/scripts/gator-repo-status.py +649 -0
  32. gator_command/scripts/gator-session-common.py +372 -0
  33. gator_command/scripts/gator-session-sink.py +831 -0
  34. gator_command/scripts/gator-sessions.py +1244 -0
  35. gator_command/scripts/gator-update.py +615 -0
  36. gator_command/scripts/gator-version.py +38 -0
  37. gator_command/scripts/gator_core.py +489 -0
  38. gator_command/scripts/gator_remote.py +381 -0
  39. gator_command/scripts/gator_runtime.py +142 -0
  40. gator_command/scripts/gatorize-actions.sh +989 -0
  41. gator_command/scripts/gatorize-lib.sh +166 -0
  42. gator_command/scripts/gatorize-post.sh +394 -0
  43. gator_command/scripts/gatorize.py +1163 -0
  44. gator_command/scripts/gatorize.sh +185 -0
  45. gator_command/scripts/generate_markdown.py +212 -0
  46. gator_command/scripts/generate_wiki.py +424 -0
  47. gator_command/scripts/graph_health.py +780 -0
  48. gator_command/scripts/memex-lint.py +286 -0
  49. gator_command/scripts/memex-lint.sh +205 -0
  50. gator_command/scripts/memex.py +1472 -0
  51. gator_command/scripts/memex_formatters.py +191 -0
  52. gator_command/scripts/memex_state.py +236 -0
  53. gator_command/scripts/spawn.py +650 -0
  54. gator_command/templates/gator-starter/blueprints/README.md +32 -0
  55. gator_command/templates/gator-starter/charterignore +53 -0
  56. gator_command/templates/gator-starter/charters/README.md +178 -0
  57. gator_command/templates/gator-starter/charters/_template.md +31 -0
  58. gator_command/templates/gator-starter/commands/commit.md +33 -0
  59. gator_command/templates/gator-starter/commands/init.md +11 -0
  60. gator_command/templates/gator-starter/commands/update.md +5 -0
  61. gator_command/templates/gator-starter/constitution.md +165 -0
  62. gator_command/templates/gator-starter/field-guides/README.md +25 -0
  63. gator_command/templates/gator-starter/gator-start-up.md +119 -0
  64. gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
  65. gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
  66. gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
  67. gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
  68. gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
  69. gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
  70. gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
  71. gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
  72. gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
  73. gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
  74. gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
  75. gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
  76. gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
  77. gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
  78. gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
  79. gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
  80. gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
  81. gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
  82. gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
  83. gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
  84. gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
  85. gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
  86. gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
  87. gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
  88. gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
  89. gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
  90. gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
  91. gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
  92. gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
  93. gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
  94. gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
  95. gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
  96. gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
  97. gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
  98. gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
  99. gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
  100. gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
  101. gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
  102. gator_command/templates/gator-starter/sessions/.gitignore +7 -0
  103. gator_command/templates/gator-starter/vault/.gitkeep +0 -0
  104. gator_command/templates/gator-starter/whiteboard.md +5 -0
  105. gator_command-1.0.0.dist-info/METADATA +122 -0
  106. gator_command-1.0.0.dist-info/RECORD +110 -0
  107. gator_command-1.0.0.dist-info/WHEEL +5 -0
  108. gator_command-1.0.0.dist-info/entry_points.txt +2 -0
  109. gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
  110. 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()