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,649 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gator repo-status — Per-repo governance status as structured JSON.
|
|
4
|
+
|
|
5
|
+
Provides a deep view of a single governed repo for the Gator Dashboard
|
|
6
|
+
Repo View: charter coverage (based on Covers: declarations), stale
|
|
7
|
+
charters, recent governed commits with full trailer data, override events,
|
|
8
|
+
and session summary count.
|
|
9
|
+
|
|
10
|
+
Charter coverage %:
|
|
11
|
+
(git-tracked source files appearing in at least one charter's Covers:
|
|
12
|
+
declaration) / (total git-tracked source files, excluding test and
|
|
13
|
+
generated files) * 100
|
|
14
|
+
|
|
15
|
+
Coverage is based on explicit Covers: declarations, not mere charter
|
|
16
|
+
file presence. A charter file existing does not mean source files are
|
|
17
|
+
covered — only files listed in Covers: count.
|
|
18
|
+
|
|
19
|
+
Charter staleness:
|
|
20
|
+
A charter is stale if any source file in its Covers: declaration has a
|
|
21
|
+
git commit timestamp newer than the charter file's own last git commit.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python gator-command/scripts/gator-repo-status.py
|
|
25
|
+
python gator-command/scripts/gator-repo-status.py --repo dangerous-golf
|
|
26
|
+
python gator-command/scripts/gator-repo-status.py --path /abs/path/to/repo
|
|
27
|
+
python gator-command/scripts/gator-repo-status.py --json
|
|
28
|
+
python gator-command/scripts/gator-repo-status.py --json --repo code-wizard
|
|
29
|
+
|
|
30
|
+
@reads: .gator/ state, git history, .gator/charters/, .gator/sessions/
|
|
31
|
+
@writes: stdout only
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from gator_core import (
|
|
42
|
+
get_version, find_command_post, normalize_path, parse_registry,
|
|
43
|
+
git, ensure_utf8_stdout,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
VERSION = get_version()
|
|
47
|
+
|
|
48
|
+
# Files excluded from the charter coverage denominator.
|
|
49
|
+
# Applied to paths as returned by git ls-files (relative to repo root).
|
|
50
|
+
_EXCLUDE_PATTERNS = [
|
|
51
|
+
re.compile(r'(^|/)tests?/', re.I),
|
|
52
|
+
re.compile(r'_test\.(py|js|ts|go|rb|java)$', re.I),
|
|
53
|
+
re.compile(r'^test_.*\.(py|js|ts|go|rb|java)$', re.I),
|
|
54
|
+
re.compile(r'(^|/)(dist|build|__pycache__|\.pytest_cache|node_modules)/', re.I),
|
|
55
|
+
re.compile(r'\.(generated|gen)\.(py|js|ts|go)$', re.I),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Charter filenames to skip when scanning .gator/charters/
|
|
59
|
+
_CHARTER_SKIP = {"_template.md", "README.md", "INDEX.md", ".gitkeep"}
|
|
60
|
+
|
|
61
|
+
# Number of recent governed commits to return in recent_trailers
|
|
62
|
+
_RECENT_LIMIT = 20
|
|
63
|
+
|
|
64
|
+
# Lookback window in days for trailer and override scanning
|
|
65
|
+
_LOOKBACK_DAYS = 30
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Helpers
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _unix_to_utc(ts_str):
|
|
73
|
+
"""Convert a Unix timestamp string to UTC ISO 8601.
|
|
74
|
+
|
|
75
|
+
Returns empty string on parse failure.
|
|
76
|
+
|
|
77
|
+
Use %at in git log formats to get Unix timestamps — never %ai/%aI,
|
|
78
|
+
which embed a local offset that str truncation would mislabel as UTC.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
return datetime.fromtimestamp(
|
|
82
|
+
int(ts_str.strip()), tz=timezone.utc
|
|
83
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
84
|
+
except (ValueError, OSError):
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_source_file(path_str):
|
|
89
|
+
"""Return True if path should count toward the charter coverage denominator."""
|
|
90
|
+
for pattern in _EXCLUDE_PATTERNS:
|
|
91
|
+
if pattern.search(path_str):
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_covers(charter_text):
|
|
97
|
+
"""Extract file/directory paths from a charter's **Covers**: line.
|
|
98
|
+
|
|
99
|
+
Handles the canonical format:
|
|
100
|
+
**Covers**: `path/to/file.py`, `path/to/dir/`
|
|
101
|
+
|
|
102
|
+
Returns a list of path strings (stripped of backticks).
|
|
103
|
+
"""
|
|
104
|
+
for line in charter_text.splitlines():
|
|
105
|
+
if re.match(r'^\*\*Covers\*\*:', line.strip()):
|
|
106
|
+
return re.findall(r'`([^`]+)`', line)
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _file_last_commit_ts(repo_path, rel_path):
|
|
111
|
+
"""Return the Unix timestamp of the last commit touching rel_path.
|
|
112
|
+
|
|
113
|
+
Returns None if git log fails or the file has no commits.
|
|
114
|
+
"""
|
|
115
|
+
raw, ok = git("log", "-1", "--format=%at", "--", rel_path, cwd=repo_path)
|
|
116
|
+
if not ok or not raw.strip():
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
return int(raw.strip())
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Hook status
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def get_hook_status(repo_path):
|
|
129
|
+
"""Return hook installation status for a governed repo.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
"present" — hook sources or installed hook detected
|
|
133
|
+
"missing" — .gator/ exists but no hooks found
|
|
134
|
+
"ungoverned" — no .gator/ directory
|
|
135
|
+
"""
|
|
136
|
+
gator_dir = repo_path / ".gator"
|
|
137
|
+
if not gator_dir.is_dir():
|
|
138
|
+
return "ungoverned"
|
|
139
|
+
|
|
140
|
+
hooks_src = gator_dir / "scripts" / "hooks"
|
|
141
|
+
git_hooks = repo_path / ".git" / "hooks"
|
|
142
|
+
|
|
143
|
+
has_sources = hooks_src.is_dir() and any(hooks_src.iterdir())
|
|
144
|
+
has_installed = (
|
|
145
|
+
(git_hooks / "pre-commit").exists() or
|
|
146
|
+
(git_hooks / "commit-msg").exists()
|
|
147
|
+
) if git_hooks.is_dir() else False
|
|
148
|
+
|
|
149
|
+
return "present" if (has_sources or has_installed) else "missing"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Charter coverage
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def get_charter_coverage(repo_path):
|
|
157
|
+
"""Compute charter coverage % and identify stale charters.
|
|
158
|
+
|
|
159
|
+
Coverage definition:
|
|
160
|
+
(source files appearing in at least one charter's Covers: declaration)
|
|
161
|
+
/ (total git-tracked source files, excluding test/generated files) * 100
|
|
162
|
+
|
|
163
|
+
Staleness definition:
|
|
164
|
+
A charter is stale if any covered source file has a git commit
|
|
165
|
+
timestamp newer than the charter file's own last git commit.
|
|
166
|
+
|
|
167
|
+
Returns a dict:
|
|
168
|
+
charter_count — number of charter files (excluding templates)
|
|
169
|
+
charter_coverage_pct — float (0–100), or None if no source files
|
|
170
|
+
stale_charters — list of stale charter dicts
|
|
171
|
+
covered_files — set of covered source file paths (internal use)
|
|
172
|
+
"""
|
|
173
|
+
gator_dir = repo_path / ".gator"
|
|
174
|
+
charters_dir = gator_dir / "charters"
|
|
175
|
+
|
|
176
|
+
result = {
|
|
177
|
+
"charter_count": 0,
|
|
178
|
+
"charter_coverage_pct": None,
|
|
179
|
+
"stale_charters": [],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Get all git-tracked source files
|
|
183
|
+
ls_out, ok = git("ls-files", cwd=repo_path)
|
|
184
|
+
if not ok:
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
all_source_files = set(
|
|
188
|
+
p for p in ls_out.splitlines()
|
|
189
|
+
if p.strip() and _is_source_file(p.strip())
|
|
190
|
+
)
|
|
191
|
+
if not all_source_files:
|
|
192
|
+
result["charter_coverage_pct"] = 0.0
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
if not charters_dir.is_dir():
|
|
196
|
+
result["charter_coverage_pct"] = 0.0
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
charter_files = [
|
|
200
|
+
f for f in charters_dir.iterdir()
|
|
201
|
+
if f.suffix == ".md" and f.name not in _CHARTER_SKIP
|
|
202
|
+
]
|
|
203
|
+
result["charter_count"] = len(charter_files)
|
|
204
|
+
|
|
205
|
+
if not charter_files:
|
|
206
|
+
result["charter_coverage_pct"] = 0.0
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
covered_files = set()
|
|
210
|
+
stale_charters = []
|
|
211
|
+
|
|
212
|
+
for charter_path in charter_files:
|
|
213
|
+
try:
|
|
214
|
+
text = charter_path.read_text(encoding="utf-8", errors="replace")
|
|
215
|
+
except OSError:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
covers = _parse_covers(text)
|
|
219
|
+
if not covers:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Resolve covered source files — each Covers: entry may be a file
|
|
223
|
+
# path or a directory prefix. Match against git ls-files output.
|
|
224
|
+
charter_covered = set()
|
|
225
|
+
for cover in covers:
|
|
226
|
+
cover = cover.rstrip("/")
|
|
227
|
+
for sf in all_source_files:
|
|
228
|
+
if sf == cover or sf.startswith(cover + "/"):
|
|
229
|
+
charter_covered.add(sf)
|
|
230
|
+
|
|
231
|
+
covered_files |= charter_covered
|
|
232
|
+
|
|
233
|
+
# Staleness check: compare charter's last commit vs each covered file
|
|
234
|
+
charter_rel = str(charter_path.relative_to(repo_path)).replace("\\", "/")
|
|
235
|
+
charter_ts = _file_last_commit_ts(repo_path, charter_rel)
|
|
236
|
+
if charter_ts is None:
|
|
237
|
+
# Charter has no commits yet — not stale (not yet in history)
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
stale_covers = []
|
|
241
|
+
latest_source_ts = None
|
|
242
|
+
for sf in charter_covered:
|
|
243
|
+
src_ts = _file_last_commit_ts(repo_path, sf)
|
|
244
|
+
if src_ts is not None:
|
|
245
|
+
if latest_source_ts is None or src_ts > latest_source_ts:
|
|
246
|
+
latest_source_ts = src_ts
|
|
247
|
+
if src_ts > charter_ts:
|
|
248
|
+
stale_covers.append(sf)
|
|
249
|
+
|
|
250
|
+
if stale_covers:
|
|
251
|
+
stale_charters.append({
|
|
252
|
+
"charter": charter_rel,
|
|
253
|
+
"covers": list(charter_covered),
|
|
254
|
+
"stale_sources": sorted(stale_covers),
|
|
255
|
+
"charter_modified": _unix_to_utc(str(charter_ts)),
|
|
256
|
+
"source_last_commit": _unix_to_utc(
|
|
257
|
+
str(latest_source_ts) if latest_source_ts else ""
|
|
258
|
+
),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
pct = round(len(covered_files) / len(all_source_files) * 100, 1)
|
|
262
|
+
result["charter_coverage_pct"] = pct
|
|
263
|
+
result["stale_charters"] = stale_charters
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Trailer scanning
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def get_trailer_data(repo_path, lookback_days=_LOOKBACK_DAYS, limit=_RECENT_LIMIT):
|
|
272
|
+
"""Scan git log for governed commits in the lookback window.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
last_governed_commit — most recent commit with Gator-* trailers
|
|
276
|
+
recent_trailers — list of up to `limit` governed commits
|
|
277
|
+
override_events — commits with Gator-Override: trailer
|
|
278
|
+
"""
|
|
279
|
+
since_str = f"{lookback_days} days ago"
|
|
280
|
+
|
|
281
|
+
# TRIPWIRE: Use %at (Unix timestamp), not %ai or %aI.
|
|
282
|
+
# %ai embeds a local timezone offset; truncating to 19 chars and
|
|
283
|
+
# appending Z mislabels local time as UTC. %at is always UTC-unambiguous.
|
|
284
|
+
#
|
|
285
|
+
# TRIPWIRE: Do NOT call str.strip() on blocks before splitting on \x1f.
|
|
286
|
+
# Python treats \x1f (chr(31), unit separator) as whitespace — strip()
|
|
287
|
+
# removes the leading field separator and collapses 4 parts to 3.
|
|
288
|
+
import subprocess as _sp
|
|
289
|
+
try:
|
|
290
|
+
proc = _sp.run(
|
|
291
|
+
[
|
|
292
|
+
"git", "log", f"--since={since_str}",
|
|
293
|
+
"--format=COMMIT\x1f%H\x1f%at\x1f%(trailers:separator=\x1e)",
|
|
294
|
+
],
|
|
295
|
+
cwd=repo_path,
|
|
296
|
+
capture_output=True,
|
|
297
|
+
text=True,
|
|
298
|
+
timeout=15,
|
|
299
|
+
)
|
|
300
|
+
except (_sp.TimeoutExpired, OSError):
|
|
301
|
+
return None, [], []
|
|
302
|
+
|
|
303
|
+
if proc.returncode != 0:
|
|
304
|
+
return None, [], []
|
|
305
|
+
|
|
306
|
+
last_governed = None
|
|
307
|
+
recent_trailers = []
|
|
308
|
+
override_events = []
|
|
309
|
+
|
|
310
|
+
for block in proc.stdout.split("COMMIT"):
|
|
311
|
+
# Do NOT strip block here — see TRIPWIRE above.
|
|
312
|
+
if not block:
|
|
313
|
+
continue
|
|
314
|
+
parts = block.split("\x1f")
|
|
315
|
+
if len(parts) < 4:
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
commit_hash = parts[1].strip()
|
|
319
|
+
commit_ts = _unix_to_utc(parts[2].strip())
|
|
320
|
+
trailers_raw = parts[3]
|
|
321
|
+
|
|
322
|
+
# Parse trailer lines
|
|
323
|
+
trailer_dict = {}
|
|
324
|
+
for line in (t.strip() for t in trailers_raw.split("\x1e") if t.strip()):
|
|
325
|
+
if ":" in line:
|
|
326
|
+
k, _, v = line.partition(":")
|
|
327
|
+
trailer_dict[k.strip()] = v.strip()
|
|
328
|
+
|
|
329
|
+
# Skip commits without Gator-* trailers
|
|
330
|
+
if not any(k.startswith("Gator-") for k in trailer_dict):
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
entry = {
|
|
334
|
+
"hash": commit_hash[:7],
|
|
335
|
+
"timestamp": commit_ts,
|
|
336
|
+
"significance": trailer_dict.get("Gator-Significance", ""),
|
|
337
|
+
"change_type": trailer_dict.get("Gator-Change-Type", ""),
|
|
338
|
+
"charter_changed": trailer_dict.get("Gator-Charter-Changed", "") not in ("no", ""),
|
|
339
|
+
"override": bool(trailer_dict.get("Gator-Override", "")),
|
|
340
|
+
"agent": trailer_dict.get("Gator-Agent", ""),
|
|
341
|
+
"architect": trailer_dict.get("Gator-Architect", trailer_dict.get("Gator-PI", "")),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# last_governed_commit — first one encountered (log is newest-first)
|
|
345
|
+
if last_governed is None:
|
|
346
|
+
last_governed = {
|
|
347
|
+
"hash": entry["hash"],
|
|
348
|
+
"timestamp": entry["timestamp"],
|
|
349
|
+
"agent": entry["agent"],
|
|
350
|
+
"architect": entry["architect"],
|
|
351
|
+
"significance": entry["significance"],
|
|
352
|
+
"change_type": entry["change_type"],
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# recent_trailers — up to limit
|
|
356
|
+
if len(recent_trailers) < limit:
|
|
357
|
+
recent_trailers.append(entry)
|
|
358
|
+
|
|
359
|
+
# Override events
|
|
360
|
+
override_val = trailer_dict.get("Gator-Override", "")
|
|
361
|
+
if override_val:
|
|
362
|
+
override_events.append({
|
|
363
|
+
"hash": commit_hash[:7],
|
|
364
|
+
"timestamp": commit_ts,
|
|
365
|
+
"override_type": override_val,
|
|
366
|
+
# Approver is Gator-Override-Approved-By, not Gator-PI.
|
|
367
|
+
# The hook writes this from .override-meta.json — it records
|
|
368
|
+
# who ran gator-approve.py, which may differ from the session Architect.
|
|
369
|
+
"approver": trailer_dict.get("Gator-Override-Approved-By", ""),
|
|
370
|
+
"block_id": trailer_dict.get("Gator-Override-Block", ""),
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
return last_governed, recent_trailers, override_events
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Session summary count
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
def get_session_summaries(repo_path, limit=20):
|
|
381
|
+
"""Read committed session summaries via the canonical parser.
|
|
382
|
+
|
|
383
|
+
Returns (total_count, recent_items) where total_count is the number
|
|
384
|
+
of parseable summaries and recent_items is up to `limit` items
|
|
385
|
+
(newest first) with metadata for dashboard display.
|
|
386
|
+
|
|
387
|
+
Uses read_committed_summaries() from gator-sessions.py — the same
|
|
388
|
+
parser used by gator-audit.py. This ensures the count and the panel
|
|
389
|
+
are consistent: both reflect only files that pass the canonical parser.
|
|
390
|
+
"""
|
|
391
|
+
sessions_dir = repo_path / ".gator" / "sessions"
|
|
392
|
+
if not sessions_dir.is_dir():
|
|
393
|
+
return 0, []
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
from gator_core import import_sibling
|
|
397
|
+
sessions_mod = import_sibling("gator-sessions")
|
|
398
|
+
except (ImportError, Exception):
|
|
399
|
+
return 0, []
|
|
400
|
+
|
|
401
|
+
# Read all summaries (since_days=9999 effectively means no time cutoff)
|
|
402
|
+
summaries = sessions_mod.read_committed_summaries(sessions_dir, since_days=9999)
|
|
403
|
+
|
|
404
|
+
items = []
|
|
405
|
+
for s in summaries:
|
|
406
|
+
items.append({
|
|
407
|
+
"date": s.get("date", ""),
|
|
408
|
+
"start": s.get("start", ""),
|
|
409
|
+
"repo": s.get("repo", ""),
|
|
410
|
+
"vendor": s.get("vendor", ""),
|
|
411
|
+
"agent": s.get("agent", ""),
|
|
412
|
+
"goal": s.get("goal", ""),
|
|
413
|
+
"decisions_count": len(s.get("decisions", [])),
|
|
414
|
+
"source_file": s.get("source_file", ""),
|
|
415
|
+
"source_kind": "local-repo",
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
# Sort by (start, date, source_file) descending
|
|
419
|
+
items.sort(
|
|
420
|
+
key=lambda x: (x.get("start", "") or x.get("date", ""), x.get("source_file", "")),
|
|
421
|
+
reverse=True,
|
|
422
|
+
)
|
|
423
|
+
return len(items), items[:limit]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
# Main scan
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
def scan_repo_status(repo_path, repo_name=None):
|
|
431
|
+
"""Assemble the full repo-status dict for a single governed repo.
|
|
432
|
+
|
|
433
|
+
Returns a dict matching the gator-repo-status JSON schema.
|
|
434
|
+
"""
|
|
435
|
+
repo_path = Path(repo_path).resolve()
|
|
436
|
+
name = repo_name or repo_path.name
|
|
437
|
+
|
|
438
|
+
data = {
|
|
439
|
+
"schema": "gator-repo-status-v1",
|
|
440
|
+
"repo": name,
|
|
441
|
+
"path": str(repo_path),
|
|
442
|
+
"accessible": repo_path.is_dir(),
|
|
443
|
+
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if not data["accessible"]:
|
|
447
|
+
return data
|
|
448
|
+
|
|
449
|
+
# Branch
|
|
450
|
+
branch, ok = git("branch", "--show-current", cwd=repo_path)
|
|
451
|
+
data["branch"] = branch if ok and branch else "unknown"
|
|
452
|
+
|
|
453
|
+
# Hook status
|
|
454
|
+
data["hook_status"] = get_hook_status(repo_path)
|
|
455
|
+
|
|
456
|
+
# Charter coverage + staleness
|
|
457
|
+
coverage = get_charter_coverage(repo_path)
|
|
458
|
+
data["charter_count"] = coverage["charter_count"]
|
|
459
|
+
data["charter_coverage_pct"] = coverage["charter_coverage_pct"]
|
|
460
|
+
data["stale_charters"] = coverage["stale_charters"]
|
|
461
|
+
|
|
462
|
+
# Trailer data: last governed commit, recent trailers, override events
|
|
463
|
+
last_governed, recent_trailers, override_events = get_trailer_data(repo_path)
|
|
464
|
+
data["last_governed_commit"] = last_governed
|
|
465
|
+
data["recent_trailers"] = recent_trailers
|
|
466
|
+
data["override_events"] = override_events
|
|
467
|
+
|
|
468
|
+
# Session summary count + recent summaries (both from canonical parser)
|
|
469
|
+
summary_count, recent_summaries = get_session_summaries(repo_path)
|
|
470
|
+
data["session_summary_count"] = summary_count
|
|
471
|
+
data["recent_session_summaries"] = recent_summaries
|
|
472
|
+
|
|
473
|
+
# Enforcement config
|
|
474
|
+
config_path = repo_path / ".gator" / "config.json"
|
|
475
|
+
config = {}
|
|
476
|
+
if config_path.exists():
|
|
477
|
+
try:
|
|
478
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
479
|
+
except (json.JSONDecodeError, OSError):
|
|
480
|
+
pass
|
|
481
|
+
data["config"] = config
|
|
482
|
+
|
|
483
|
+
# Topology
|
|
484
|
+
try:
|
|
485
|
+
from gator_core import get_repo_topology
|
|
486
|
+
data["topology"] = get_repo_topology(repo_path / ".gator")
|
|
487
|
+
except ImportError:
|
|
488
|
+
data["topology"] = "unknown"
|
|
489
|
+
|
|
490
|
+
return data
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
# Repo resolution
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
def resolve_repo(repo_name=None, repo_path_arg=None):
|
|
498
|
+
"""Find a repo path + name from --repo or --path argument.
|
|
499
|
+
|
|
500
|
+
Resolution order:
|
|
501
|
+
1. --path given → use directly, infer name from directory
|
|
502
|
+
2. --repo given → look up in registry by name
|
|
503
|
+
3. Neither → use current working directory
|
|
504
|
+
"""
|
|
505
|
+
if repo_path_arg:
|
|
506
|
+
p = Path(normalize_path(repo_path_arg)).resolve()
|
|
507
|
+
return p, p.name
|
|
508
|
+
|
|
509
|
+
if repo_name:
|
|
510
|
+
cp = find_command_post()
|
|
511
|
+
if cp:
|
|
512
|
+
for entry in parse_registry(cp):
|
|
513
|
+
if entry["name"] == repo_name:
|
|
514
|
+
return Path(normalize_path(entry["path"])).resolve(), entry["name"]
|
|
515
|
+
print(f" Error: repo '{repo_name}' not found in registry.", file=sys.stderr)
|
|
516
|
+
sys.exit(1)
|
|
517
|
+
|
|
518
|
+
# Default: current working directory
|
|
519
|
+
cwd = Path.cwd().resolve()
|
|
520
|
+
return cwd, cwd.name
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
# Text output
|
|
525
|
+
# ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
def render_text(data):
|
|
528
|
+
"""Render repo-status as human-readable terminal output."""
|
|
529
|
+
lines = [""]
|
|
530
|
+
|
|
531
|
+
accessible = data.get("accessible", False)
|
|
532
|
+
name = data.get("repo", "?")
|
|
533
|
+
path = data.get("path", "?")
|
|
534
|
+
|
|
535
|
+
lines.append(f" gator repo-status: {name}")
|
|
536
|
+
lines.append(f" {data.get('generated_at', '')} | {VERSION}")
|
|
537
|
+
lines.append("")
|
|
538
|
+
|
|
539
|
+
if not accessible:
|
|
540
|
+
lines.append(f" ✗ {path} — NOT ACCESSIBLE")
|
|
541
|
+
lines.append("")
|
|
542
|
+
return "\n".join(lines)
|
|
543
|
+
|
|
544
|
+
# Overview
|
|
545
|
+
branch = data.get("branch", "?")
|
|
546
|
+
hook = data.get("hook_status", "?")
|
|
547
|
+
hook_indicator = "✓" if hook == "present" else ("✗" if hook == "missing" else "·")
|
|
548
|
+
lines.append(f" Branch: {branch} Hooks: {hook_indicator} {hook}")
|
|
549
|
+
lines.append("")
|
|
550
|
+
|
|
551
|
+
# Charter coverage
|
|
552
|
+
pct = data.get("charter_coverage_pct")
|
|
553
|
+
count = data.get("charter_count", 0)
|
|
554
|
+
pct_str = f"{pct}%" if pct is not None else "n/a"
|
|
555
|
+
stale = data.get("stale_charters", [])
|
|
556
|
+
stale_indicator = f" ⚠ {len(stale)} stale" if stale else ""
|
|
557
|
+
lines.append(f" CHARTERS: {count} charters | Coverage: {pct_str}{stale_indicator}")
|
|
558
|
+
if stale:
|
|
559
|
+
for s in stale:
|
|
560
|
+
lines.append(f" ⚠ {s['charter']}")
|
|
561
|
+
for src in s.get("stale_sources", [])[:3]:
|
|
562
|
+
lines.append(f" source newer: {src}")
|
|
563
|
+
lines.append("")
|
|
564
|
+
|
|
565
|
+
# Last governed commit
|
|
566
|
+
lgc = data.get("last_governed_commit")
|
|
567
|
+
if lgc:
|
|
568
|
+
lines.append(f" LAST GOVERNED COMMIT: {lgc['hash']} {lgc.get('timestamp', '')[:10]}")
|
|
569
|
+
lines.append(f" Agent: {lgc.get('agent', '?')} Architect: {lgc.get('architect', '?')}")
|
|
570
|
+
lines.append(f" Significance: {lgc.get('significance', '?')} Type: {lgc.get('change_type', '?')}")
|
|
571
|
+
else:
|
|
572
|
+
lines.append(f" LAST GOVERNED COMMIT: none in last {_LOOKBACK_DAYS} days")
|
|
573
|
+
lines.append("")
|
|
574
|
+
|
|
575
|
+
# Recent trailers summary
|
|
576
|
+
trailers = data.get("recent_trailers", [])
|
|
577
|
+
if trailers:
|
|
578
|
+
lines.append(f" RECENT GOVERNED COMMITS ({len(trailers)} in last {_LOOKBACK_DAYS} days)")
|
|
579
|
+
for t in trailers[:5]:
|
|
580
|
+
override_flag = " [OVERRIDE]" if t.get("override") else ""
|
|
581
|
+
charter_flag = " [charter]" if t.get("charter_changed") else ""
|
|
582
|
+
lines.append(
|
|
583
|
+
f" {t['hash']} {t.get('timestamp', '')[:10]}"
|
|
584
|
+
f" {t.get('significance', '?'):8} {t.get('change_type', '?')}"
|
|
585
|
+
f"{charter_flag}{override_flag}"
|
|
586
|
+
)
|
|
587
|
+
if len(trailers) > 5:
|
|
588
|
+
lines.append(f" ... and {len(trailers) - 5} more")
|
|
589
|
+
else:
|
|
590
|
+
lines.append(f" RECENT GOVERNED COMMITS: none in last {_LOOKBACK_DAYS} days")
|
|
591
|
+
lines.append("")
|
|
592
|
+
|
|
593
|
+
# Overrides
|
|
594
|
+
overrides = data.get("override_events", [])
|
|
595
|
+
if overrides:
|
|
596
|
+
lines.append(f" OVERRIDE EVENTS ({len(overrides)})")
|
|
597
|
+
for o in overrides:
|
|
598
|
+
approver = f" approver: {o['approver']}" if o.get("approver") else ""
|
|
599
|
+
lines.append(f" {o['hash']} {o.get('timestamp', '')[:10]} {o.get('override_type', '?')}{approver}")
|
|
600
|
+
lines.append("")
|
|
601
|
+
|
|
602
|
+
# Sessions
|
|
603
|
+
session_count = data.get("session_summary_count", 0)
|
|
604
|
+
lines.append(f" SESSION SUMMARIES: {session_count}")
|
|
605
|
+
lines.append("")
|
|
606
|
+
|
|
607
|
+
return "\n".join(lines)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# ---------------------------------------------------------------------------
|
|
611
|
+
# Entry point
|
|
612
|
+
# ---------------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
def main():
|
|
615
|
+
ensure_utf8_stdout()
|
|
616
|
+
|
|
617
|
+
parser = argparse.ArgumentParser(
|
|
618
|
+
description="Gator repo-status — per-repo governance status."
|
|
619
|
+
)
|
|
620
|
+
parser.add_argument(
|
|
621
|
+
"--repo", "-r",
|
|
622
|
+
help="Repo name from registry (e.g. dangerous-golf)",
|
|
623
|
+
)
|
|
624
|
+
parser.add_argument(
|
|
625
|
+
"--path", "-p",
|
|
626
|
+
help="Absolute path to repo (alternative to --repo)",
|
|
627
|
+
)
|
|
628
|
+
parser.add_argument(
|
|
629
|
+
"--json", "-j",
|
|
630
|
+
action="store_true",
|
|
631
|
+
help="Output as JSON",
|
|
632
|
+
)
|
|
633
|
+
args = parser.parse_args()
|
|
634
|
+
|
|
635
|
+
repo_path, repo_name = resolve_repo(
|
|
636
|
+
repo_name=args.repo,
|
|
637
|
+
repo_path_arg=getattr(args, "path", None),
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
data = scan_repo_status(repo_path, repo_name)
|
|
641
|
+
|
|
642
|
+
if args.json:
|
|
643
|
+
print(json.dumps(data, indent=2, default=str))
|
|
644
|
+
else:
|
|
645
|
+
print(render_text(data))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
if __name__ == "__main__":
|
|
649
|
+
main()
|