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,956 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ gator audit — Convergence dashboard for fleet governance posture.
4
+
5
+ Assembles data from fleet-report, drift, sessions, and charters into
6
+ a unified audit view. Three output modes: terminal text (PI/developer),
7
+ JSON (automation), HTML (CISO/browser, self-contained, no external deps).
8
+
9
+ This is the presentation layer for everything Gator knows. No new data
10
+ collection — intelligence from existing data sources.
11
+
12
+ Usage:
13
+ python gator-command/scripts/gator-audit.py
14
+ python gator-command/scripts/gator-audit.py --json
15
+ python gator-command/scripts/gator-audit.py --html
16
+ python gator-command/scripts/gator-audit.py --html --open
17
+ python gator-command/scripts/gator-audit.py --since 7d
18
+
19
+ @reads: fleet-report data, drift data, session index, .gator/ state
20
+ @writes: stdout (text/json) or audit-report.html (--html)
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+ import webbrowser
29
+ from datetime import datetime, timedelta, timezone
30
+ from pathlib import Path
31
+
32
+ from gator_core import get_version, ensure_utf8_stdout, import_sibling
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Setup
37
+ # ---------------------------------------------------------------------------
38
+
39
+ VERSION = get_version()
40
+
41
+ # Alias for readability in this script
42
+ _import_script = import_sibling
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Data Assembly
47
+ # ---------------------------------------------------------------------------
48
+
49
+ # Session choreography phrases that are NOT governance decisions
50
+ _CHOREOGRAPHY = {
51
+ "yes", "ok", "no", "sure", "thanks", "thank you", "got it",
52
+ "yes, proceed", "yes, update it", "do it", "proceed", "approved",
53
+ "go ahead", "sounds good", "looks good", "continue", "next",
54
+ "let's continue", "let's move on",
55
+ }
56
+
57
+ # Patterns that indicate session choreography, not governance decisions
58
+ _CHOREOGRAPHY_PATTERNS = [
59
+ "let's review", "let's look", "let's check", "let's see",
60
+ "let's commit", "let's push", "let's merge",
61
+ "let's get", "let's do", "let's start", "let's try",
62
+ "please review", "please check", "review pass", "review please",
63
+ "read the constitution", "read the org policy", "read the mission",
64
+ "gator init", "gator update", "gator commit",
65
+ "yes, read", "yes, re-read", "yes, make", "yes, draft",
66
+ "make all", "fix that", "fix this",
67
+ ]
68
+
69
+
70
+ def _is_real_decision(text):
71
+ """Filter session choreography from governance decisions.
72
+
73
+ A real decision changes direction, commits to an approach, or
74
+ resolves a tradeoff. Session choreography (review requests,
75
+ confirmations, navigation) is not governance evidence.
76
+ """
77
+ if not text or len(text) < 20:
78
+ return False
79
+
80
+ # Skip tool results
81
+ if text.startswith("[tool result"):
82
+ return False
83
+
84
+ lower = text.lower().strip()
85
+
86
+ # Skip bare confirmations
87
+ if lower in _CHOREOGRAPHY:
88
+ return False
89
+
90
+ # Skip choreography patterns (check anywhere in text, not just start)
91
+ for pattern in _CHOREOGRAPHY_PATTERNS:
92
+ if pattern in lower:
93
+ return False
94
+
95
+ # Require substantive content — must contain a noun-like word
96
+ # beyond just "let's" + verb
97
+ words = lower.split()
98
+ if len(words) < 4:
99
+ return False
100
+
101
+ return True
102
+
103
+
104
+ def _collect_trailer_intelligence(fleet_status, since_days):
105
+ """Scan commit trailers across accessible fleet repos.
106
+
107
+ Produces three datasets used by the dashboard Audit view:
108
+ - override_events: commits where Gator-Override: trailer was present
109
+ - significance_distribution: counts of each Gator-Significance value
110
+ - governed_commits: governed commit counts per repo + fleet total
111
+
112
+ A governed commit is any commit carrying at least one Gator-* trailer.
113
+ Runs git log directly against each local repo path.
114
+
115
+ @reads: git log from each accessible local repo path
116
+ """
117
+ override_events = []
118
+ sig_dist = {}
119
+ governed_commits = {}
120
+
121
+ since_str = (datetime.now(timezone.utc) - timedelta(days=since_days)).strftime("%Y-%m-%d")
122
+
123
+ for repo in fleet_status:
124
+ if not repo.get("accessible"):
125
+ continue
126
+ path = repo.get("path", "")
127
+ name = repo.get("name", "?")
128
+ if not path or not Path(path).is_dir():
129
+ continue
130
+
131
+ try:
132
+ result = subprocess.run(
133
+ [
134
+ "git", "log", f"--since={since_str}",
135
+ # %at = Unix timestamp (seconds since epoch) — always UTC,
136
+ # no offset ambiguity. Do NOT use %ai/%aI: those embed a
137
+ # local offset that we'd have to parse to convert to UTC.
138
+ "--format=COMMIT\x1f%H\x1f%at\x1f%(trailers:separator=\x1e)",
139
+ ],
140
+ cwd=path,
141
+ capture_output=True,
142
+ text=True,
143
+ timeout=10,
144
+ )
145
+ if result.returncode != 0:
146
+ continue
147
+
148
+ repo_governed = 0
149
+ for block in result.stdout.split("COMMIT"):
150
+ # Do NOT strip block — \x1f is treated as whitespace by Python's
151
+ # str.strip(), which would remove the leading field separator and
152
+ # collapse 4 parts into 3.
153
+ if not block:
154
+ continue
155
+ parts = block.split("\x1f")
156
+ if len(parts) < 4:
157
+ continue
158
+ commit_hash = parts[1].strip()
159
+ # Convert Unix timestamp to UTC ISO 8601
160
+ try:
161
+ commit_ts = datetime.fromtimestamp(
162
+ int(parts[2].strip()), tz=timezone.utc
163
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
164
+ except (ValueError, OSError):
165
+ commit_ts = ""
166
+ trailers_raw = parts[3]
167
+
168
+ # Parse trailer lines into a dict
169
+ trailer_dict = {}
170
+ for line in (t.strip() for t in trailers_raw.split("\x1e") if t.strip()):
171
+ if ":" in line:
172
+ k, _, v = line.partition(":")
173
+ trailer_dict[k.strip()] = v.strip()
174
+
175
+ # Only count commits with Gator-* trailers
176
+ if not any(k.startswith("Gator-") for k in trailer_dict):
177
+ continue
178
+
179
+ repo_governed += 1
180
+
181
+ # Override events
182
+ override_val = trailer_dict.get("Gator-Override", "")
183
+ if override_val:
184
+ override_events.append({
185
+ "repo": name,
186
+ "hash": commit_hash[:7],
187
+ "timestamp": commit_ts,
188
+ "override_type": override_val,
189
+ # Approver comes from Gator-Override-Approved-By, not
190
+ # Gator-Architect. The hook writes this trailer from
191
+ # .override-meta.json at commit time — it records who
192
+ # actually ran gator-approve.py, which may differ from
193
+ # the session PI identity.
194
+ "approver": trailer_dict.get("Gator-Override-Approved-By", ""),
195
+ "block_id": trailer_dict.get("Gator-Override-Block", ""),
196
+ })
197
+
198
+ # Significance distribution
199
+ sig = trailer_dict.get("Gator-Significance", "").lower()
200
+ if sig:
201
+ sig_dist[sig] = sig_dist.get(sig, 0) + 1
202
+
203
+ governed_commits[name] = repo_governed
204
+
205
+ except (subprocess.TimeoutExpired, OSError, ValueError):
206
+ continue
207
+
208
+ governed_commits["total"] = sum(v for k, v in governed_commits.items() if k != "total")
209
+
210
+ # Sort overrides most-recent first
211
+ override_events.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
212
+
213
+ return override_events, sig_dist, governed_commits
214
+
215
+
216
+ def assemble_audit_data(since_days=7):
217
+ """Assemble all audit data from existing Gator subsystems.
218
+
219
+ Returns a unified dict with fleet_status, drift, sessions,
220
+ governance, decisions, and metadata.
221
+ """
222
+ data = {
223
+ "schema": "gator-audit-v1",
224
+ "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
225
+ "generated_local": datetime.now().strftime("%Y-%m-%d %H:%M"),
226
+ "version": VERSION,
227
+ "since_days": since_days,
228
+ "machine": {},
229
+ "fleet_status": [],
230
+ "drift": [],
231
+ "drift_summary": {},
232
+ "sessions": {},
233
+ "governance": {},
234
+ "decisions": [],
235
+ }
236
+
237
+ # Machine identity
238
+ try:
239
+ common = _import_script("gator-session-common")
240
+ except ImportError as e:
241
+ common = None
242
+ data["_errors"] = data.get("_errors", []) + [f"session-common: {e}"]
243
+ if common:
244
+ data["machine"] = common.get_machine_identity()
245
+
246
+ # Fleet report
247
+ try:
248
+ fleet_mod = _import_script("gator-fleet-report")
249
+ except ImportError as e:
250
+ fleet_mod = None
251
+ data["fleet_status"] = [{"error": f"import failed: {e}"}]
252
+ if fleet_mod:
253
+ try:
254
+ cp = fleet_mod.find_command_post()
255
+ if cp:
256
+ repos = fleet_mod.parse_registry(cp)
257
+ reports = fleet_mod.scan_fleet(repos)
258
+ data["fleet_status"] = reports
259
+ except (OSError, KeyError, ValueError) as e:
260
+ data["fleet_status"] = [{"error": f"{type(e).__name__}: {e}"}]
261
+
262
+ # Drift
263
+ try:
264
+ drift_mod = _import_script("gator-drift")
265
+ except ImportError as e:
266
+ drift_mod = None
267
+ data["drift"] = [{"error": f"import failed: {e}"}]
268
+ if drift_mod:
269
+ try:
270
+ cp = drift_mod.find_command_post()
271
+ if cp:
272
+ repos = drift_mod.parse_registry(cp)
273
+ cp_state = drift_mod.read_command_post_policy(cp)
274
+ results = [drift_mod.check_repo_drift(r, cp_state) for r in repos]
275
+ data["drift"] = results
276
+ data["drift_summary"] = {
277
+ "command_post": cp_state,
278
+ "ok": sum(1 for r in results if r["severity"] == "ok"),
279
+ "warn": sum(1 for r in results if r["severity"] == "warn"),
280
+ "drift": sum(1 for r in results if r["severity"] == "drift"),
281
+ }
282
+ except (OSError, KeyError, ValueError) as e:
283
+ data["drift"] = [{"error": f"{type(e).__name__}: {e}"}]
284
+
285
+ # Sessions — filtered to fleet repos only
286
+ # discover_all_sessions() reads raw vendor logs from the entire machine.
287
+ # Unfiltered, this includes sessions for repos not in the fleet (personal
288
+ # projects, experiments, etc.) which inflates "Sessions by agent" counts
289
+ # and makes "Session coverage" meaningless. Filter to fleet repo names.
290
+ try:
291
+ sessions_mod = _import_script("gator-sessions")
292
+ except ImportError as e:
293
+ sessions_mod = None
294
+ data["sessions"] = {"error": f"import failed: {e}"}
295
+ if sessions_mod:
296
+ try:
297
+ all_sessions = sessions_mod.discover_all_sessions()
298
+
299
+ # Build fleet repo name set for filtering
300
+ fleet_names = set()
301
+ for r in data["fleet_status"]:
302
+ name = r.get("name", "")
303
+ if name:
304
+ fleet_names.add(name)
305
+
306
+ # Filter to fleet repos only
307
+ fleet_sessions = [
308
+ s for s in all_sessions
309
+ if (s.get("project", "") or "unknown") in fleet_names
310
+ ]
311
+
312
+ # Filter by recency
313
+ since_str = f"{since_days}d"
314
+ since_dt = sessions_mod.parse_since(since_str)
315
+ recent = sessions_mod.filter_sessions_since(fleet_sessions, since_dt) if since_dt else fleet_sessions
316
+
317
+ # Pending count (fleet sessions only)
318
+ pending = len([
319
+ s for s in sessions_mod.get_pending_sessions(all_sessions)
320
+ if (s.get("project", "") or "unknown") in fleet_names
321
+ ])
322
+
323
+ # By vendor
324
+ by_vendor = {}
325
+ for s in recent:
326
+ v = s["vendor"]
327
+ by_vendor[v] = by_vendor.get(v, 0) + 1
328
+
329
+ # By repo
330
+ by_repo = {}
331
+ for s in recent:
332
+ r = s.get("project", "") or "unknown"
333
+ by_repo[r] = by_repo.get(r, 0) + 1
334
+
335
+ data["sessions"] = {
336
+ "total": len(fleet_sessions),
337
+ "recent": len(recent),
338
+ "since_days": since_days,
339
+ "by_vendor": by_vendor,
340
+ "by_repo": dict(sorted(by_repo.items(), key=lambda x: -x[1])),
341
+ "pending_export": pending,
342
+ "exported": len(fleet_sessions) - pending,
343
+ }
344
+ except (OSError, KeyError, ValueError, json.JSONDecodeError) as e:
345
+ data["sessions"] = {"error": f"{type(e).__name__}: {e}"}
346
+
347
+ # Governance coverage (from fleet data)
348
+ if data["fleet_status"]:
349
+ accessible = [r for r in data["fleet_status"] if r.get("accessible")]
350
+ total_charters = sum(r.get("charters", 0) for r in accessible)
351
+ total_functions = sum(r.get("functions", 0) for r in accessible)
352
+ total_issues = sum(r.get("issues", 0) for r in accessible)
353
+ with_hooks = sum(1 for r in accessible if r.get("has_hooks"))
354
+ with_trailers = sum(1 for r in accessible if r.get("trailers"))
355
+
356
+ data["governance"] = {
357
+ "repos": len(accessible),
358
+ "charters": total_charters,
359
+ "functions": total_functions,
360
+ "issues": total_issues,
361
+ "hooks_installed": with_hooks,
362
+ "trailers_flowing": with_trailers,
363
+ }
364
+
365
+ # Trailer intelligence: override events, significance distribution, governed commits
366
+ data["override_events"] = []
367
+ data["significance_distribution"] = {}
368
+ data["governed_commits"] = {}
369
+ if data["fleet_status"]:
370
+ accessible = [r for r in data["fleet_status"] if r.get("accessible")]
371
+ try:
372
+ ov, sig, gov_c = _collect_trailer_intelligence(accessible, since_days)
373
+ data["override_events"] = ov
374
+ data["significance_distribution"] = sig
375
+ data["governed_commits"] = gov_c
376
+ except Exception as e:
377
+ data["_errors"] = data.get("_errors", []) + [
378
+ f"trailer intelligence: {type(e).__name__}: {e}"
379
+ ]
380
+
381
+ # Recent decisions — prefer committed summaries, fall back to raw vendor logs
382
+ #
383
+ # The committed summary layer (.gator/sessions/) is the durable, portable
384
+ # read path. Scans both the command post AND fleet repos' .gator/sessions/
385
+ # directories. Fleet repos produce commit summaries automatically via the
386
+ # post-commit hook; the command post has archaeology-based summaries.
387
+ #
388
+ # Fallback to raw vendor logs only if no committed summaries exist anywhere.
389
+ committed_decisions = []
390
+ if sessions_mod:
391
+ # Collect all sessions directories: command post + fleet repos
392
+ # Each entry is (sessions_dir, source_kind) for provenance tagging.
393
+ sessions_dirs = []
394
+ sessions_dirs_tagged = [] # (dir, source_kind)
395
+ try:
396
+ from gator_core import find_command_post, parse_registry, normalize_path
397
+ cp = find_command_post()
398
+ if cp:
399
+ cp_sessions = cp / ".gator" / "sessions"
400
+ if cp_sessions.is_dir():
401
+ sessions_dirs.append(cp_sessions)
402
+ sessions_dirs_tagged.append((cp_sessions, "command-post"))
403
+ # Also scan fleet repos' .gator/sessions/
404
+ for repo in parse_registry(cp):
405
+ repo_path = Path(normalize_path(repo["path"]))
406
+ repo_sessions = repo_path / ".gator" / "sessions"
407
+ if repo_sessions.is_dir():
408
+ sessions_dirs.append(repo_sessions)
409
+ sessions_dirs_tagged.append((repo_sessions, "local-repo"))
410
+ elif repo.get("remote") and repo["remote"] != "—":
411
+ # Remote fallback: read sessions from bare cache
412
+ try:
413
+ from gator_remote import (
414
+ ensure_cache, list_committed_sessions_remote,
415
+ read_session_summary_remote, CACHE_DIR, _cache_key,
416
+ )
417
+ cache_path = CACHE_DIR / _cache_key(repo["name"], repo["remote"])
418
+ if cache_path.is_dir() or ensure_cache(repo["name"], repo["remote"]):
419
+ remote_sessions = list_committed_sessions_remote(cache_path)
420
+ if remote_sessions:
421
+ # Store for later processing
422
+ if not hasattr(data, "_remote_sessions"):
423
+ data["_remote_sessions"] = []
424
+ data["_remote_sessions"].append({
425
+ "repo": repo["name"],
426
+ "cache_path": cache_path,
427
+ "files": remote_sessions,
428
+ })
429
+ except ImportError:
430
+ pass
431
+ except ImportError:
432
+ pass
433
+
434
+ has_committed = any(
435
+ any(d.glob("*.md")) for d in sessions_dirs
436
+ ) if sessions_dirs else False
437
+ has_remote_sessions = bool(data.get("_remote_sessions"))
438
+
439
+ if has_committed or has_remote_sessions:
440
+ # Read from committed summaries across all repos (durable path)
441
+ data["decisions_source"] = "committed"
442
+ data["decisions_dirs"] = len(sessions_dirs)
443
+ all_summaries = []
444
+ for sdir, source_kind in sessions_dirs_tagged:
445
+ for s in sessions_mod.read_committed_summaries(sdir, since_days):
446
+ s["source_kind"] = source_kind
447
+ all_summaries.append(s)
448
+ # Also read remote session summaries from bare caches
449
+ # Uses the same parse_committed_summary() as the local path
450
+ # to ensure identical decision extraction behavior.
451
+ if has_remote_sessions:
452
+ try:
453
+ from gator_remote import read_session_summary_remote
454
+ for remote_info in data.pop("_remote_sessions"):
455
+ for filename in remote_info["files"]:
456
+ content = read_session_summary_remote(
457
+ remote_info["cache_path"], filename
458
+ )
459
+ if content:
460
+ result = sessions_mod.parse_committed_summary(
461
+ content, filename
462
+ )
463
+ if result:
464
+ result["source_kind"] = "remote-cache"
465
+ all_summaries.append(result)
466
+ except ImportError:
467
+ pass
468
+
469
+ # Build summary metadata with provenance for drill-down
470
+ summary_items = []
471
+ for s in all_summaries:
472
+ summary_items.append({
473
+ "date": s.get("date", ""),
474
+ "start": s.get("start", ""),
475
+ "repo": s.get("repo", ""),
476
+ "vendor": s.get("vendor", ""),
477
+ "agent": s.get("agent", ""),
478
+ "goal": s.get("goal", ""),
479
+ "decisions_count": len(s.get("decisions", [])),
480
+ "source_file": s.get("source_file", ""),
481
+ "source_kind": s.get("source_kind", "command-post"),
482
+ })
483
+ summary_items.sort(
484
+ key=lambda x: (x.get("start", "") or x.get("date", ""), x.get("source_file", "")),
485
+ reverse=True,
486
+ )
487
+ data["session_summaries"] = summary_items[:50]
488
+
489
+ for s in all_summaries:
490
+ for d in s.get("decisions", []):
491
+ text = d.get("text", "")
492
+ if not _is_real_decision(text):
493
+ continue
494
+ committed_decisions.append({
495
+ "timestamp": d.get("timestamp", ""),
496
+ "text": text,
497
+ "repo": s.get("repo", ""),
498
+ "vendor": s.get("vendor", ""),
499
+ })
500
+ elif common:
501
+ # Fallback: raw vendor session logs (fragile, machine-dependent)
502
+ data["decisions_source"] = "raw-vendor-logs"
503
+ try:
504
+ all_sessions_list = sessions_mod.discover_all_sessions()
505
+ since_dt = sessions_mod.parse_since(f"{since_days}d")
506
+ recent_sessions = sessions_mod.filter_sessions_since(all_sessions_list, since_dt) if since_dt else all_sessions_list
507
+
508
+ vendor_extractors = {
509
+ "claude": ("extract-claude-sessions", lambda mod, path: (mod.extract_session(path), None)),
510
+ "codex": ("extract-codex-sessions", lambda mod, path: mod.extract_session(path)),
511
+ "gemini": ("extract-gemini-sessions", lambda mod, path: mod.extract_session(path)),
512
+ }
513
+
514
+ by_repo = {}
515
+ for s in recent_sessions:
516
+ repo = s.get("project", "") or "unknown"
517
+ by_repo.setdefault(repo, []).append(s)
518
+
519
+ sampled = []
520
+ for pick in range(3):
521
+ for repo in sorted(by_repo.keys()):
522
+ items = by_repo[repo]
523
+ if pick < len(items):
524
+ sampled.append(items[pick])
525
+
526
+ for s in sampled:
527
+ vendor = s["vendor"]
528
+ extractor_info = vendor_extractors.get(vendor)
529
+ if not extractor_info:
530
+ continue
531
+ mod_name, extract_fn = extractor_info
532
+ try:
533
+ mod = _import_script(mod_name)
534
+ if not mod:
535
+ continue
536
+ result = extract_fn(mod, Path(s["path"]))
537
+ turns = result[0] if isinstance(result, tuple) else result
538
+ intel = common.extract_intelligence(turns)
539
+ for d in intel.get("decisions", []):
540
+ text = d["text"]
541
+ if not _is_real_decision(text):
542
+ continue
543
+ committed_decisions.append({
544
+ "timestamp": d["timestamp"],
545
+ "text": text,
546
+ "repo": s.get("project", ""),
547
+ "vendor": vendor,
548
+ })
549
+ except (ImportError, OSError, KeyError, ValueError,
550
+ json.JSONDecodeError, UnicodeDecodeError):
551
+ continue
552
+ except (OSError, KeyError, ValueError) as e:
553
+ data["_errors"] = data.get("_errors", []) + [
554
+ f"decisions assembly: {type(e).__name__}: {e}"
555
+ ]
556
+
557
+ # Sort by timestamp, most recent first, cap at 15
558
+ committed_decisions.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
559
+ data["decisions"] = committed_decisions[:15]
560
+
561
+ return data
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # Text Output
566
+ # ---------------------------------------------------------------------------
567
+
568
+ def render_text(data):
569
+ """Render audit data as terminal text."""
570
+ lines = []
571
+
572
+ lines.append("")
573
+ lines.append(f" gator audit")
574
+ lines.append(f" {data['generated_local']} | {data['version']} | {data.get('machine', {}).get('label', '?')}")
575
+ lines.append("")
576
+
577
+ # Build drift lookup for fleet indicator
578
+ drift_by_name = {}
579
+ for d in data.get("drift", []):
580
+ drift_by_name[d.get("name", "")] = d.get("severity", "ok")
581
+
582
+ # Fleet Status
583
+ fleet = data.get("fleet_status", [])
584
+ if fleet:
585
+ lines.append(f" FLEET STATUS ({len(fleet)} repos)")
586
+ lines.append("")
587
+ for r in fleet:
588
+ if not r.get("accessible"):
589
+ lines.append(f" ✗ {r.get('name', '?')} — NOT ACCESSIBLE")
590
+ continue
591
+
592
+ hooks = "✓" if r.get("has_hooks") else "✗"
593
+ trailers = "✓" if r.get("trailers") else "·"
594
+
595
+ # Indicator reflects governance health, not just issues
596
+ repo_drift = drift_by_name.get(r.get("name", ""), "ok")
597
+ if repo_drift == "drift":
598
+ indicator = "✗"
599
+ elif repo_drift == "warn" or not r.get("has_hooks"):
600
+ indicator = "⚠"
601
+ else:
602
+ indicator = "✓"
603
+
604
+ charters = r.get("charters", 0)
605
+ functions = r.get("functions", 0)
606
+
607
+ commit = r.get("last_commit")
608
+ commit_str = ""
609
+ if commit and not commit.get("error"):
610
+ commit_str = f" last: {commit['age']}"
611
+
612
+ lines.append(
613
+ f" {indicator} {r.get('name', '?'):<20}"
614
+ f" charters: {charters:>2} ({functions:>2} fn)"
615
+ f" hooks: {hooks} trailers: {trailers}"
616
+ f"{commit_str}"
617
+ )
618
+ lines.append("")
619
+
620
+ # Drift
621
+ drift_list = data.get("drift", [])
622
+ drift_sum = data.get("drift_summary", {})
623
+ if drift_list:
624
+ ok = drift_sum.get("ok", 0)
625
+ warn = drift_sum.get("warn", 0)
626
+ drifted = drift_sum.get("drift", 0)
627
+
628
+ # Surface command-post git failure once, not per-repo
629
+ cp_state = drift_sum.get("command_post", {})
630
+ if cp_state.get("git_failed"):
631
+ lines.append(f" DRIFT (command post git access failed — policy comparison skipped)")
632
+ else:
633
+ lines.append(f" DRIFT ({ok} current, {warn} warnings, {drifted} drifted)")
634
+ lines.append("")
635
+
636
+ seen_cp_warn = False
637
+ for r in drift_list:
638
+ if r.get("severity") == "ok":
639
+ continue
640
+ name = r.get("name", "?")
641
+ sev = "✗" if r["severity"] == "drift" else "⚠"
642
+ lines.append(f" {sev} {name}")
643
+ for f in r.get("findings", []):
644
+ # Deduplicate command-post git failure across repos
645
+ if f["check"] == "policy-version" and "git failed" in f.get("message", ""):
646
+ if not seen_cp_warn:
647
+ seen_cp_warn = True
648
+ marker = "⚠"
649
+ lines.append(f" {marker} {f['check']}: {f['message']}")
650
+ continue
651
+ marker = "✗" if f["severity"] == "drift" else "⚠"
652
+ lines.append(f" {marker} {f['check']}: {f['message']}")
653
+ if drifted == 0 and warn == 0:
654
+ lines.append(f" All repos current.")
655
+ lines.append("")
656
+
657
+ # Sessions
658
+ sessions = data.get("sessions", {})
659
+ if sessions and not sessions.get("error"):
660
+ since = sessions.get("since_days", 7)
661
+ recent = sessions.get("recent", 0)
662
+ total = sessions.get("total", 0)
663
+ lines.append(f" SESSIONS (last {since} days: {recent} of {total} total)")
664
+ lines.append("")
665
+
666
+ by_vendor = sessions.get("by_vendor", {})
667
+ if by_vendor:
668
+ vendor_parts = [f"{v}: {c}" for v, c in sorted(by_vendor.items())]
669
+ lines.append(f" Vendors: {' | '.join(vendor_parts)}")
670
+
671
+ by_repo = sessions.get("by_repo", {})
672
+ if by_repo:
673
+ repo_parts = [f"{r}({c})" for r, c in list(by_repo.items())[:6]]
674
+ lines.append(f" Repos: {', '.join(repo_parts)}")
675
+
676
+ pending = sessions.get("pending_export", 0)
677
+ exported = sessions.get("exported", 0)
678
+ lines.append(f" Export: {exported} exported, {pending} pending")
679
+ lines.append("")
680
+
681
+ # Governance Coverage
682
+ gov = data.get("governance", {})
683
+ if gov:
684
+ lines.append(f" GOVERNANCE COVERAGE")
685
+ lines.append("")
686
+ lines.append(f" Charters: {gov.get('charters', 0)} across {gov.get('repos', 0)} repos ({gov.get('functions', 0)} functions)")
687
+ lines.append(f" Hooks: {gov.get('hooks_installed', 0)}/{gov.get('repos', 0)} repos")
688
+ lines.append(f" Trailers: {gov.get('trailers_flowing', 0)}/{gov.get('repos', 0)} repos")
689
+ lines.append(f" Issues: {gov.get('issues', 0)} open")
690
+ lines.append("")
691
+
692
+ # Recent Decisions
693
+ decisions = data.get("decisions", [])
694
+ if decisions:
695
+ lines.append(f" RECENT DECISIONS ({len(decisions)})")
696
+ lines.append("")
697
+ for d in decisions[:10]:
698
+ ts = d.get("timestamp", "")[:10]
699
+ repo = d.get("repo", "")
700
+ repo_str = f" [{repo}]" if repo else ""
701
+ text = d.get("text", "")[:80]
702
+ lines.append(f" [{ts}]{repo_str} {text}")
703
+ lines.append("")
704
+
705
+ lines.append(f" Generated: {data['generated_local']} | Machine: {data.get('machine', {}).get('label', '?')}")
706
+ lines.append("")
707
+
708
+ return "\n".join(lines)
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # HTML Output
713
+ # ---------------------------------------------------------------------------
714
+
715
+ def render_html(data):
716
+ """Render audit data as a self-contained HTML file.
717
+
718
+ Single file, inline CSS, no JavaScript dependencies, no external
719
+ resources. Opens in any browser. Can be saved as PDF, emailed,
720
+ attached to compliance reports. Air-gapped compatible.
721
+ """
722
+ fleet = data.get("fleet_status", [])
723
+ drift = data.get("drift", [])
724
+ drift_sum = data.get("drift_summary", {})
725
+ sessions = data.get("sessions", {})
726
+ gov = data.get("governance", {})
727
+ decisions = data.get("decisions", [])
728
+ machine = data.get("machine", {})
729
+
730
+ # Build drift lookup for fleet indicator
731
+ drift_by_name = {}
732
+ for d in drift:
733
+ drift_by_name[d.get("name", "")] = d.get("severity", "ok")
734
+
735
+ # Fleet rows
736
+ fleet_rows = ""
737
+ for r in fleet:
738
+ if not r.get("accessible"):
739
+ fleet_rows += f'<tr class="drift"><td>{r.get("name","?")}</td><td colspan="5">NOT ACCESSIBLE</td></tr>\n'
740
+ continue
741
+
742
+ repo_drift = drift_by_name.get(r.get("name", ""), "ok")
743
+ if repo_drift == "drift":
744
+ status_class = "drift"
745
+ elif repo_drift == "warn" or not r.get("has_hooks"):
746
+ status_class = "warn"
747
+ else:
748
+ status_class = "ok"
749
+ hooks = "&#10003;" if r.get("has_hooks") else "&#10007;"
750
+ hooks_class = "ok" if r.get("has_hooks") else "drift"
751
+ trailers = "&#10003;" if r.get("trailers") else "&middot;"
752
+ trailers_class = "ok" if r.get("trailers") else "muted"
753
+
754
+ commit = r.get("last_commit")
755
+ age = commit.get("age", "?") if commit and not commit.get("error") else "?"
756
+
757
+ fleet_rows += f'''<tr class="{status_class}">
758
+ <td><strong>{r.get("name","?")}</strong></td>
759
+ <td>{r.get("charters",0)} ({r.get("functions",0)} fn)</td>
760
+ <td class="{hooks_class}">{hooks}</td>
761
+ <td class="{trailers_class}">{trailers}</td>
762
+ <td>{r.get("issues",0)}</td>
763
+ <td>{age}</td>
764
+ </tr>\n'''
765
+
766
+ # Drift rows — deduplicate command-post git failure (same as text view)
767
+ drift_rows = ""
768
+ cp_state = drift_sum.get("command_post", {})
769
+ cp_git_failed = cp_state.get("git_failed", False)
770
+ seen_cp_warn = False
771
+
772
+ if cp_git_failed:
773
+ drift_rows += '<tr class="warn"><td colspan="3">Command post git access failed — policy comparison skipped for all repos</td></tr>\n'
774
+
775
+ for r in drift:
776
+ if r.get("severity") == "ok":
777
+ continue
778
+ for f in r.get("findings", []):
779
+ # Deduplicate command-post git failure
780
+ if f["check"] == "policy-version" and "git failed" in f.get("message", ""):
781
+ continue
782
+ sev_class = "drift" if f["severity"] == "drift" else "warn"
783
+ drift_rows += f'<tr class="{sev_class}"><td>{r.get("name","?")}</td><td>{f["check"]}</td><td>{f["message"]}</td></tr>\n'
784
+
785
+ if not drift_rows:
786
+ drift_rows = '<tr class="ok"><td colspan="3">All repos current. No drift detected.</td></tr>'
787
+
788
+ # Session stats
789
+ by_vendor_html = ""
790
+ for v, c in sorted(sessions.get("by_vendor", {}).items()):
791
+ by_vendor_html += f'<span class="tag">{v}: {c}</span> '
792
+
793
+ # Decision rows
794
+ decision_rows = ""
795
+ for d in decisions[:10]:
796
+ ts = d.get("timestamp", "")[:10]
797
+ repo = d.get("repo", "")
798
+ text = d.get("text", "")[:100]
799
+ decision_rows += f'<tr><td>{ts}</td><td>{repo}</td><td>{text}</td></tr>\n'
800
+
801
+ if not decision_rows:
802
+ decision_rows = '<tr><td colspan="3">No decisions extracted from recent sessions.</td></tr>'
803
+
804
+ ok_count = drift_sum.get("ok", 0)
805
+ warn_count = drift_sum.get("warn", 0)
806
+ drift_count = drift_sum.get("drift", 0)
807
+ since_days = sessions.get("since_days", 7)
808
+ recent_sessions = sessions.get("recent", 0)
809
+ total_sessions = sessions.get("total", 0)
810
+ pending = sessions.get("pending_export", 0)
811
+ exported = sessions.get("exported", 0)
812
+
813
+ html = f'''<!DOCTYPE html>
814
+ <html lang="en">
815
+ <head>
816
+ <meta charset="UTF-8">
817
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
818
+ <title>Gator Audit — {data["generated_local"]}</title>
819
+ <style>
820
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
821
+ body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
822
+ background: #0d1117; color: #c9d1d9; padding: 24px; line-height: 1.5; }}
823
+ .container {{ max-width: 960px; margin: 0 auto; }}
824
+ h1 {{ color: #58a6ff; font-size: 1.4em; margin-bottom: 4px; }}
825
+ h2 {{ color: #8b949e; font-size: 1.1em; margin: 24px 0 12px 0;
826
+ border-bottom: 1px solid #21262d; padding-bottom: 6px; }}
827
+ .subtitle {{ color: #8b949e; font-size: 0.85em; margin-bottom: 20px; }}
828
+ table {{ width: 100%; border-collapse: collapse; margin: 8px 0 16px 0; font-size: 0.85em; }}
829
+ th {{ text-align: left; color: #8b949e; padding: 6px 10px; border-bottom: 1px solid #21262d; }}
830
+ td {{ padding: 6px 10px; border-bottom: 1px solid #161b22; }}
831
+ tr:hover {{ background: #161b22; }}
832
+ .ok {{ color: #3fb950; }}
833
+ .warn {{ color: #d29922; }}
834
+ .drift {{ color: #f85149; }}
835
+ .muted {{ color: #484f58; }}
836
+ .stats {{ display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0; }}
837
+ .stat {{ background: #161b22; border: 1px solid #21262d; border-radius: 6px;
838
+ padding: 12px 16px; min-width: 140px; }}
839
+ .stat-value {{ font-size: 1.6em; font-weight: bold; color: #58a6ff; }}
840
+ .stat-label {{ font-size: 0.8em; color: #8b949e; }}
841
+ .tag {{ background: #21262d; padding: 2px 8px; border-radius: 3px;
842
+ font-size: 0.8em; margin-right: 4px; }}
843
+ .footer {{ margin-top: 32px; padding-top: 12px; border-top: 1px solid #21262d;
844
+ color: #484f58; font-size: 0.75em; }}
845
+ @media print {{
846
+ body {{ background: white; color: #1f2328; }}
847
+ .stat {{ border-color: #d0d7de; }}
848
+ tr:hover {{ background: transparent; }}
849
+ }}
850
+ </style>
851
+ </head>
852
+ <body>
853
+ <div class="container">
854
+
855
+ <h1>Gator Audit Dashboard</h1>
856
+ <div class="subtitle">{data["generated_local"]} &middot; {data["version"]} &middot; {machine.get("label", "?")}</div>
857
+
858
+ <div class="stats">
859
+ <div class="stat"><div class="stat-value">{gov.get("repos", 0)}</div><div class="stat-label">Repos</div></div>
860
+ <div class="stat"><div class="stat-value">{gov.get("charters", 0)}</div><div class="stat-label">Charters</div></div>
861
+ <div class="stat"><div class="stat-value">{gov.get("functions", 0)}</div><div class="stat-label">Functions</div></div>
862
+ <div class="stat"><div class="stat-value {("ok" if drift_count == 0 else "drift")}">{drift_count}</div><div class="stat-label">Drifted</div></div>
863
+ <div class="stat"><div class="stat-value">{recent_sessions}</div><div class="stat-label">Sessions ({since_days}d)</div></div>
864
+ <div class="stat"><div class="stat-value">{gov.get("issues", 0)}</div><div class="stat-label">Issues</div></div>
865
+ </div>
866
+
867
+ <h2>Fleet Status</h2>
868
+ <table>
869
+ <tr><th>Repo</th><th>Charters</th><th>Hooks</th><th>Trailers</th><th>Issues</th><th>Last Commit</th></tr>
870
+ {fleet_rows}
871
+ </table>
872
+
873
+ <h2>Drift Findings</h2>
874
+ <div class="subtitle">{ok_count} current, {warn_count} warnings, {drift_count} drifted</div>
875
+ <table>
876
+ <tr><th>Repo</th><th>Check</th><th>Finding</th></tr>
877
+ {drift_rows}
878
+ </table>
879
+
880
+ <h2>Sessions (last {since_days} days)</h2>
881
+ <div class="subtitle">{recent_sessions} of {total_sessions} total &middot; {exported} exported, {pending} pending</div>
882
+ <div style="margin: 8px 0;">{by_vendor_html}</div>
883
+
884
+ <h2>Recent Decisions</h2>
885
+ <table>
886
+ <tr><th>Date</th><th>Repo</th><th>Decision</th></tr>
887
+ {decision_rows}
888
+ </table>
889
+
890
+ <h2>Governance Coverage</h2>
891
+ <table>
892
+ <tr><th>Metric</th><th>Value</th></tr>
893
+ <tr><td>Charters</td><td>{gov.get("charters",0)} across {gov.get("repos",0)} repos ({gov.get("functions",0)} functions documented)</td></tr>
894
+ <tr><td>Hooks Installed</td><td class="{"ok" if gov.get("hooks_installed",0) == gov.get("repos",0) else "warn"}">{gov.get("hooks_installed",0)} / {gov.get("repos",0)} repos</td></tr>
895
+ <tr><td>Trailers Flowing</td><td class="{"ok" if gov.get("trailers_flowing",0) == gov.get("repos",0) else "warn"}">{gov.get("trailers_flowing",0)} / {gov.get("repos",0)} repos</td></tr>
896
+ <tr><td>Open Issues</td><td>{gov.get("issues",0)}</td></tr>
897
+ </table>
898
+
899
+ <div class="footer">
900
+ Generated by Gator {data["version"]} &middot; {data["generated_at"]} &middot;
901
+ Machine: {machine.get("label", "?")} ({machine.get("id", "?")[:8]}) &middot;
902
+ <a href="https://github.com/cumberland-laboratories/gator" style="color: #484f58;">cumberland-laboratories/gator</a>
903
+ </div>
904
+
905
+ </div>
906
+ </body>
907
+ </html>'''
908
+
909
+ return html
910
+
911
+
912
+ # ---------------------------------------------------------------------------
913
+ # Entry point
914
+ # ---------------------------------------------------------------------------
915
+
916
+ def main():
917
+ ensure_utf8_stdout()
918
+
919
+ parser = argparse.ArgumentParser(
920
+ description="Gator audit — fleet governance dashboard."
921
+ )
922
+ parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
923
+ parser.add_argument("--html", action="store_true", help="Generate HTML dashboard")
924
+ parser.add_argument("--open", "-o", action="store_true", help="Open HTML in browser (with --html)")
925
+ parser.add_argument("--since", "-s", default="7d", help="Lookback period for sessions (e.g., 7d, 30d)")
926
+ parser.add_argument("--output", help="Output file path (default: stdout or audit-report.html)")
927
+ args = parser.parse_args()
928
+
929
+ # Parse since
930
+ import re
931
+ since_days = 7
932
+ match = re.match(r'^(\d+)\s*d$', args.since.lower())
933
+ if match:
934
+ since_days = int(match.group(1))
935
+
936
+ # Assemble
937
+ data = assemble_audit_data(since_days=since_days)
938
+
939
+ if args.json:
940
+ print(json.dumps(data, indent=2, default=str))
941
+
942
+ elif args.html:
943
+ html = render_html(data)
944
+ output_path = args.output or "audit-report.html"
945
+ Path(output_path).write_text(html, encoding="utf-8")
946
+ print(f" Audit dashboard written to: {output_path}")
947
+ if args.open:
948
+ webbrowser.open(f"file://{Path(output_path).resolve()}")
949
+ print(f" Opened in browser.")
950
+
951
+ else:
952
+ print(render_text(data))
953
+
954
+
955
+ if __name__ == "__main__":
956
+ main()