gator-command 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gator_command/__init__.py +2 -0
- gator_command/cli.py +137 -0
- gator_command/scripts/crawler.py +633 -0
- gator_command/scripts/dashboard/dashboard.css +982 -0
- gator_command/scripts/dashboard/dashboard.html +84 -0
- gator_command/scripts/dashboard/dashboard.js +419 -0
- gator_command/scripts/dashboard/views/audit.js +270 -0
- gator_command/scripts/dashboard/views/fleet.js +307 -0
- gator_command/scripts/dashboard/views/repo.js +599 -0
- gator_command/scripts/dashboard/views/settings.js +173 -0
- gator_command/scripts/dashboard/views/updates.js +308 -0
- gator_command/scripts/enforcer-prompt.md +22 -0
- gator_command/scripts/extract-claude-sessions.py +489 -0
- gator_command/scripts/extract-codex-sessions.py +477 -0
- gator_command/scripts/extract-gemini-sessions.py +410 -0
- gator_command/scripts/gator-audit.py +956 -0
- gator_command/scripts/gator-charter-draft.py +919 -0
- gator_command/scripts/gator-charter-lint.py +427 -0
- gator_command/scripts/gator-charter-verify.py +606 -0
- gator_command/scripts/gator-dashboard.py +1271 -0
- gator_command/scripts/gator-deploy.py +916 -0
- gator_command/scripts/gator-drift.py +569 -0
- gator_command/scripts/gator-enforce.py +82 -0
- gator_command/scripts/gator-fleet-intel.py +460 -0
- gator_command/scripts/gator-fleet-report.py +615 -0
- gator_command/scripts/gator-init-command-post.py +315 -0
- gator_command/scripts/gator-init.py +434 -0
- gator_command/scripts/gator-machine-id.py +153 -0
- gator_command/scripts/gator-policy-status.py +631 -0
- gator_command/scripts/gator-pulse.py +459 -0
- gator_command/scripts/gator-repo-status.py +649 -0
- gator_command/scripts/gator-session-common.py +372 -0
- gator_command/scripts/gator-session-sink.py +831 -0
- gator_command/scripts/gator-sessions.py +1244 -0
- gator_command/scripts/gator-update.py +615 -0
- gator_command/scripts/gator-version.py +38 -0
- gator_command/scripts/gator_core.py +489 -0
- gator_command/scripts/gator_remote.py +381 -0
- gator_command/scripts/gator_runtime.py +142 -0
- gator_command/scripts/gatorize-actions.sh +989 -0
- gator_command/scripts/gatorize-lib.sh +166 -0
- gator_command/scripts/gatorize-post.sh +394 -0
- gator_command/scripts/gatorize.py +1163 -0
- gator_command/scripts/gatorize.sh +185 -0
- gator_command/scripts/generate_markdown.py +212 -0
- gator_command/scripts/generate_wiki.py +424 -0
- gator_command/scripts/graph_health.py +780 -0
- gator_command/scripts/memex-lint.py +286 -0
- gator_command/scripts/memex-lint.sh +205 -0
- gator_command/scripts/memex.py +1472 -0
- gator_command/scripts/memex_formatters.py +191 -0
- gator_command/scripts/memex_state.py +236 -0
- gator_command/scripts/spawn.py +650 -0
- gator_command/templates/gator-starter/blueprints/README.md +32 -0
- gator_command/templates/gator-starter/charterignore +53 -0
- gator_command/templates/gator-starter/charters/README.md +178 -0
- gator_command/templates/gator-starter/charters/_template.md +31 -0
- gator_command/templates/gator-starter/commands/commit.md +33 -0
- gator_command/templates/gator-starter/commands/init.md +11 -0
- gator_command/templates/gator-starter/commands/update.md +5 -0
- gator_command/templates/gator-starter/constitution.md +165 -0
- gator_command/templates/gator-starter/field-guides/README.md +25 -0
- gator_command/templates/gator-starter/gator-start-up.md +119 -0
- gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
- gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
- gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
- gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
- gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
- gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
- gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
- gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
- gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
- gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
- gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
- gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
- gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
- gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
- gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
- gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
- gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
- gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
- gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
- gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
- gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
- gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
- gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
- gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
- gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
- gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
- gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
- gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
- gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
- gator_command/templates/gator-starter/sessions/.gitignore +7 -0
- gator_command/templates/gator-starter/vault/.gitkeep +0 -0
- gator_command/templates/gator-starter/whiteboard.md +5 -0
- gator_command-1.0.0.dist-info/METADATA +122 -0
- gator_command-1.0.0.dist-info/RECORD +110 -0
- gator_command-1.0.0.dist-info/WHEEL +5 -0
- gator_command-1.0.0.dist-info/entry_points.txt +2 -0
- gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
- gator_command-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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 = "✓" if r.get("has_hooks") else "✗"
|
|
750
|
+
hooks_class = "ok" if r.get("has_hooks") else "drift"
|
|
751
|
+
trailers = "✓" if r.get("trailers") else "·"
|
|
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"]} · {data["version"]} · {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 · {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"]} · {data["generated_at"]} ·
|
|
901
|
+
Machine: {machine.get("label", "?")} ({machine.get("id", "?")[:8]}) ·
|
|
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()
|