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,615 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gator fleet-report — Cross-repo governance status from git + .gator/ state.
|
|
4
|
+
|
|
5
|
+
Reads the fleet registry, visits each repo's local path, and produces a
|
|
6
|
+
governance status report from git history and .gator/ file state. When
|
|
7
|
+
Gator-* trailers are present in commits, those are included as richer
|
|
8
|
+
metadata. The report works without trailers — they're a bonus layer.
|
|
9
|
+
|
|
10
|
+
Resolution order per repo:
|
|
11
|
+
1. Try local path → full scan (working tree + git history + .gator/)
|
|
12
|
+
2. If local inaccessible AND remote URL exists → thin-fetch scan
|
|
13
|
+
(bare clone cache, git show/log against remote refs)
|
|
14
|
+
3. If neither → report as unreachable
|
|
15
|
+
|
|
16
|
+
Use --remote to force thin-fetch for ALL repos (skip local checkouts entirely).
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python gator-command/scripts/gator-fleet-report.py
|
|
20
|
+
python gator-command/scripts/gator-fleet-report.py --json
|
|
21
|
+
python gator-command/scripts/gator-fleet-report.py --repo dangerous-golf
|
|
22
|
+
python gator-command/scripts/gator-fleet-report.py --remote
|
|
23
|
+
|
|
24
|
+
@reads: gator-command/registry.md, .gator/ in each registered repo, git history,
|
|
25
|
+
~/.gator/fleet-cache/ (bare clones for remote scanning)
|
|
26
|
+
@writes: ~/.gator/fleet-cache/ (creates/updates bare clones when remote scanning)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from gator_core import (
|
|
37
|
+
get_version, find_command_post, normalize_path, parse_registry,
|
|
38
|
+
git, ensure_utf8_stdout,
|
|
39
|
+
)
|
|
40
|
+
from gator_remote import scan_repo_remote
|
|
41
|
+
|
|
42
|
+
VERSION = get_version()
|
|
43
|
+
|
|
44
|
+
# Policy status integration — graceful degradation if module unavailable
|
|
45
|
+
try:
|
|
46
|
+
from gator_core import import_sibling
|
|
47
|
+
_ps = import_sibling("gator-policy-status")
|
|
48
|
+
if _ps:
|
|
49
|
+
_compute_sync_state = _ps.compute_sync_state
|
|
50
|
+
_get_governance_source = _ps.get_governance_source
|
|
51
|
+
_load_policy_link = _ps.load_policy_link
|
|
52
|
+
_HAS_POLICY_STATUS = True
|
|
53
|
+
else:
|
|
54
|
+
_HAS_POLICY_STATUS = False
|
|
55
|
+
except Exception:
|
|
56
|
+
_HAS_POLICY_STATUS = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_last_commit(repo_path):
|
|
60
|
+
"""Get last commit info from a repo."""
|
|
61
|
+
log_line, ok = git(
|
|
62
|
+
"log", "-1", "--format=%h|%s|%cr|%ci", "dev",
|
|
63
|
+
cwd=repo_path,
|
|
64
|
+
)
|
|
65
|
+
if not ok or not log_line:
|
|
66
|
+
# Try current branch if dev doesn't exist
|
|
67
|
+
log_line, ok = git(
|
|
68
|
+
"log", "-1", "--format=%h|%s|%cr|%ci",
|
|
69
|
+
cwd=repo_path,
|
|
70
|
+
)
|
|
71
|
+
if not ok:
|
|
72
|
+
return {"error": "git log failed"}
|
|
73
|
+
if not log_line:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
parts = log_line.split("|", 3)
|
|
77
|
+
if len(parts) < 4:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"hash": parts[0],
|
|
82
|
+
"message": parts[1],
|
|
83
|
+
"age": parts[2],
|
|
84
|
+
"date": parts[3],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_commit_count(repo_path, days=30):
|
|
89
|
+
"""Count commits in the last N days."""
|
|
90
|
+
count, ok = git(
|
|
91
|
+
"rev-list", "--count", f"--since={days} days ago", "HEAD",
|
|
92
|
+
cwd=repo_path,
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
return int(count)
|
|
96
|
+
except ValueError:
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_current_branch(repo_path):
|
|
101
|
+
"""Get current branch name."""
|
|
102
|
+
branch, ok = git("branch", "--show-current", cwd=repo_path)
|
|
103
|
+
if not ok:
|
|
104
|
+
return "error: git failed"
|
|
105
|
+
return branch or "detached"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_working_tree_status(repo_path):
|
|
109
|
+
"""Check if working tree is clean."""
|
|
110
|
+
status, ok = git("status", "--porcelain", cwd=repo_path)
|
|
111
|
+
if not ok:
|
|
112
|
+
return "error: git failed"
|
|
113
|
+
if not status:
|
|
114
|
+
return "clean"
|
|
115
|
+
lines = [l for l in status.splitlines() if l.strip()]
|
|
116
|
+
return f"{len(lines)} changed"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_latest_trailers(repo_path):
|
|
120
|
+
"""Extract Gator-* trailers from the latest commit."""
|
|
121
|
+
raw, ok = git("log", "-1", "--format=%(trailers)", "dev", cwd=repo_path)
|
|
122
|
+
if not raw:
|
|
123
|
+
raw, ok = git("log", "-1", "--format=%(trailers)", cwd=repo_path)
|
|
124
|
+
if not raw:
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
trailers = {}
|
|
128
|
+
for line in raw.splitlines():
|
|
129
|
+
if line.startswith("Gator-"):
|
|
130
|
+
key, _, value = line.partition(":")
|
|
131
|
+
trailers[key.strip()] = value.strip()
|
|
132
|
+
|
|
133
|
+
return trailers
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# .gator/ state readers
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def read_gator_state(repo_path):
|
|
141
|
+
"""Read governance state from .gator/ files."""
|
|
142
|
+
gator_dir = repo_path / ".gator"
|
|
143
|
+
state = {
|
|
144
|
+
"gatorized": gator_dir.is_dir(),
|
|
145
|
+
"generation": 0,
|
|
146
|
+
"policy_version": None,
|
|
147
|
+
"charters": 0,
|
|
148
|
+
"functions": 0,
|
|
149
|
+
"threads": 0,
|
|
150
|
+
"issues": 0,
|
|
151
|
+
"mission_summary": None,
|
|
152
|
+
"has_hooks": False,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if not state["gatorized"]:
|
|
156
|
+
return state
|
|
157
|
+
|
|
158
|
+
# Generation and last-updated timestamp
|
|
159
|
+
version_file = gator_dir / ".gator-version"
|
|
160
|
+
if version_file.exists():
|
|
161
|
+
for line in version_file.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
162
|
+
if line.startswith("generation:"):
|
|
163
|
+
try:
|
|
164
|
+
state["generation"] = int(line.split(":", 1)[1].strip())
|
|
165
|
+
except ValueError:
|
|
166
|
+
pass
|
|
167
|
+
elif line.startswith("updated:"):
|
|
168
|
+
state["gator_updated"] = line.split(":", 1)[1].strip()
|
|
169
|
+
|
|
170
|
+
# Policy version
|
|
171
|
+
cp_file = gator_dir / "command-post.md"
|
|
172
|
+
if cp_file.exists():
|
|
173
|
+
for line in cp_file.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
174
|
+
if line.startswith("version:"):
|
|
175
|
+
state["policy_version"] = line.split(":", 1)[1].strip()
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
# Charters
|
|
179
|
+
charters_dir = gator_dir / "charters"
|
|
180
|
+
if charters_dir.is_dir():
|
|
181
|
+
skip = {"_template.md", "README.md", "INDEX.md", ".gitkeep"}
|
|
182
|
+
charter_files = [
|
|
183
|
+
f for f in charters_dir.iterdir()
|
|
184
|
+
if f.suffix == ".md" and f.name not in skip
|
|
185
|
+
]
|
|
186
|
+
state["charters"] = len(charter_files)
|
|
187
|
+
|
|
188
|
+
func_count = 0
|
|
189
|
+
for cf in charter_files:
|
|
190
|
+
try:
|
|
191
|
+
text = cf.read_text(encoding="utf-8", errors="replace")
|
|
192
|
+
for line in text.splitlines():
|
|
193
|
+
if line.strip().startswith("### ") and "(" in line:
|
|
194
|
+
func_count += 1
|
|
195
|
+
except OSError:
|
|
196
|
+
continue
|
|
197
|
+
state["functions"] = func_count
|
|
198
|
+
|
|
199
|
+
# Threads
|
|
200
|
+
for subdir in ("active-threads", "threads"):
|
|
201
|
+
tdir = gator_dir / subdir
|
|
202
|
+
if tdir.is_dir():
|
|
203
|
+
state["threads"] += len([
|
|
204
|
+
f for f in tdir.iterdir()
|
|
205
|
+
if f.suffix == ".md" and f.name != ".gitkeep"
|
|
206
|
+
])
|
|
207
|
+
|
|
208
|
+
# Issues
|
|
209
|
+
issues_file = gator_dir / "issues.md"
|
|
210
|
+
if issues_file.exists():
|
|
211
|
+
text = issues_file.read_text(encoding="utf-8", errors="replace")
|
|
212
|
+
for line in text.splitlines():
|
|
213
|
+
if "**Status**: Open" in line or "**Status**: Working" in line:
|
|
214
|
+
state["issues"] += 1
|
|
215
|
+
|
|
216
|
+
# Mission one-liner
|
|
217
|
+
mission_file = gator_dir / "mission.md"
|
|
218
|
+
if mission_file.exists():
|
|
219
|
+
text = mission_file.read_text(encoding="utf-8", errors="replace")
|
|
220
|
+
for line in text.splitlines():
|
|
221
|
+
stripped = line.strip()
|
|
222
|
+
if stripped and not stripped.startswith("#") and not stripped.startswith("["):
|
|
223
|
+
state["mission_summary"] = stripped[:80]
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
# Hook presence — check both sources (.gator/scripts/hooks/) and
|
|
227
|
+
# installed (.git/hooks/pre-commit, commit-msg)
|
|
228
|
+
hooks_src = gator_dir / "scripts" / "hooks"
|
|
229
|
+
git_hooks = repo_path / ".git" / "hooks"
|
|
230
|
+
has_sources = hooks_src.is_dir() and any(hooks_src.iterdir())
|
|
231
|
+
has_installed = (
|
|
232
|
+
(git_hooks / "pre-commit").exists() or
|
|
233
|
+
(git_hooks / "commit-msg").exists()
|
|
234
|
+
) if git_hooks.is_dir() else False
|
|
235
|
+
state["has_hooks"] = has_sources or has_installed
|
|
236
|
+
state["hooks_sources"] = has_sources
|
|
237
|
+
state["hooks_installed"] = has_installed
|
|
238
|
+
|
|
239
|
+
return state
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Policy link
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def get_policy_link_local(repo_path):
|
|
247
|
+
"""Get policy sync status for a local repo.
|
|
248
|
+
|
|
249
|
+
Returns a dict with state, authority, and provenance fields.
|
|
250
|
+
Falls back gracefully if policy-status module is unavailable.
|
|
251
|
+
"""
|
|
252
|
+
if not _HAS_POLICY_STATUS:
|
|
253
|
+
return {"state": "unavailable", "authority": "none"}
|
|
254
|
+
|
|
255
|
+
gator_dir = repo_path / ".gator"
|
|
256
|
+
if not gator_dir.is_dir():
|
|
257
|
+
return {"state": "unavailable", "authority": "none"}
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
source, is_derived = _get_governance_source(gator_dir)
|
|
261
|
+
if not source:
|
|
262
|
+
return {"state": "unknown", "authority": "none"}
|
|
263
|
+
|
|
264
|
+
status = _compute_sync_state(gator_dir, source)
|
|
265
|
+
state = status.get("state", "unknown")
|
|
266
|
+
|
|
267
|
+
# Authoritative = we actually compared against the source.
|
|
268
|
+
# synced/behind/diverged always require a source comparison.
|
|
269
|
+
# no-cache is only authoritative when source_type is "local"
|
|
270
|
+
# (we verified against the local source and confirmed no cache).
|
|
271
|
+
# Remote-only no-cache means we haven't done a freshness check.
|
|
272
|
+
source_type = status.get("source_type")
|
|
273
|
+
authoritative = state in ("synced", "behind", "diverged") or (
|
|
274
|
+
state == "no-cache" and source_type == "local"
|
|
275
|
+
)
|
|
276
|
+
return {
|
|
277
|
+
"state": state,
|
|
278
|
+
"authority": "authoritative" if authoritative else "non-authoritative",
|
|
279
|
+
"source_type": status.get("source_type"),
|
|
280
|
+
"remote_url": source.get("remote_url"),
|
|
281
|
+
"cached_at": status.get("cached_at"),
|
|
282
|
+
"source_commit": status.get("source_commit"),
|
|
283
|
+
"cached_commit": status.get("cached_commit"),
|
|
284
|
+
}
|
|
285
|
+
except Exception:
|
|
286
|
+
return {"state": "unavailable", "authority": "none"}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_policy_link_remote(repo_path):
|
|
290
|
+
"""Get policy link info for a remote repo from cached policy-link.json.
|
|
291
|
+
|
|
292
|
+
Remote repos cannot compute authoritative sync state — we can only
|
|
293
|
+
report what's cached.
|
|
294
|
+
"""
|
|
295
|
+
if not _HAS_POLICY_STATUS:
|
|
296
|
+
return {"state": "unavailable", "authority": "none"}
|
|
297
|
+
|
|
298
|
+
gator_dir = repo_path / ".gator"
|
|
299
|
+
if not gator_dir.is_dir():
|
|
300
|
+
return {"state": "unknown", "authority": "none"}
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
link = _load_policy_link(gator_dir)
|
|
304
|
+
if link:
|
|
305
|
+
return {
|
|
306
|
+
"state": "cached",
|
|
307
|
+
"authority": "non-authoritative",
|
|
308
|
+
"source_type": link.get("source_type"),
|
|
309
|
+
"remote_url": link.get("remote_url"),
|
|
310
|
+
"cached_at": link.get("cached_at"),
|
|
311
|
+
"cached_commit": link.get("source_commit"),
|
|
312
|
+
}
|
|
313
|
+
return {"state": "unknown", "authority": "none"}
|
|
314
|
+
except Exception:
|
|
315
|
+
return {"state": "unavailable", "authority": "none"}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# Report assembly
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def scan_repo(repo_entry, force_remote=False):
|
|
323
|
+
"""Scan a single registered repo. Returns full status dict.
|
|
324
|
+
|
|
325
|
+
If force_remote is True, skip local checkout and use thin-fetch.
|
|
326
|
+
Otherwise, try local first and fall back to remote if inaccessible.
|
|
327
|
+
"""
|
|
328
|
+
repo_path = Path(normalize_path(repo_entry["path"]))
|
|
329
|
+
|
|
330
|
+
if force_remote:
|
|
331
|
+
return scan_repo_remote(repo_entry)
|
|
332
|
+
|
|
333
|
+
report = {
|
|
334
|
+
"name": repo_entry["name"],
|
|
335
|
+
"path": str(repo_path),
|
|
336
|
+
"remote": repo_entry.get("remote", "—"),
|
|
337
|
+
"registered": repo_entry.get("registered", "—"),
|
|
338
|
+
"accessible": repo_path.is_dir(),
|
|
339
|
+
"scan_mode": "local",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if not report["accessible"]:
|
|
343
|
+
# Fallback: try remote if URL available
|
|
344
|
+
remote = repo_entry.get("remote", "—")
|
|
345
|
+
if remote and remote != "—":
|
|
346
|
+
return scan_repo_remote(repo_entry)
|
|
347
|
+
return report
|
|
348
|
+
|
|
349
|
+
# Git state
|
|
350
|
+
report["branch"] = get_current_branch(repo_path)
|
|
351
|
+
report["last_commit"] = get_last_commit(repo_path)
|
|
352
|
+
report["commits_30d"] = get_commit_count(repo_path, 30)
|
|
353
|
+
report["working_tree"] = get_working_tree_status(repo_path)
|
|
354
|
+
report["trailers"] = get_latest_trailers(repo_path)
|
|
355
|
+
|
|
356
|
+
# .gator/ state
|
|
357
|
+
gator = read_gator_state(repo_path)
|
|
358
|
+
report.update(gator)
|
|
359
|
+
|
|
360
|
+
# Policy link (authoritative for local repos)
|
|
361
|
+
report["policy_link"] = get_policy_link_local(repo_path)
|
|
362
|
+
|
|
363
|
+
return report
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _add_remote_policy_link(report):
|
|
367
|
+
"""Add policy_link for remote-scanned repos.
|
|
368
|
+
|
|
369
|
+
Remote repos can't compute authoritative sync state. Check if the
|
|
370
|
+
remote has a policy-link.json via the bare cache.
|
|
371
|
+
"""
|
|
372
|
+
if report.get("policy_link"):
|
|
373
|
+
return # Already set (local scan)
|
|
374
|
+
if not report.get("accessible"):
|
|
375
|
+
report["policy_link"] = {"state": "unavailable", "authority": "none"}
|
|
376
|
+
return
|
|
377
|
+
if report.get("scan_mode") != "remote":
|
|
378
|
+
report.setdefault("policy_link", {"state": "unavailable", "authority": "none"})
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
# Try reading policy-link.json from bare cache
|
|
382
|
+
if _HAS_POLICY_STATUS:
|
|
383
|
+
try:
|
|
384
|
+
from gator_remote import CACHE_DIR, _cache_key, _resolve_ref, git_show
|
|
385
|
+
import json as _json
|
|
386
|
+
name = report["name"]
|
|
387
|
+
remote = report.get("remote", "")
|
|
388
|
+
if remote and remote != "—":
|
|
389
|
+
cache_path = CACHE_DIR / _cache_key(name, remote)
|
|
390
|
+
if cache_path.is_dir():
|
|
391
|
+
ref = _resolve_ref(cache_path)
|
|
392
|
+
content = git_show(cache_path, ref, ".gator/policy-link.json")
|
|
393
|
+
if content:
|
|
394
|
+
link = _json.loads(content)
|
|
395
|
+
report["policy_link"] = {
|
|
396
|
+
"state": "cached",
|
|
397
|
+
"authority": "non-authoritative",
|
|
398
|
+
"source_type": link.get("source_type"),
|
|
399
|
+
"remote_url": link.get("remote_url"),
|
|
400
|
+
"cached_at": link.get("cached_at"),
|
|
401
|
+
"cached_commit": link.get("source_commit"),
|
|
402
|
+
}
|
|
403
|
+
return
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
report["policy_link"] = {"state": "unknown", "authority": "none"}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def scan_fleet(repos, force_remote=False):
|
|
411
|
+
"""Scan all registered repos."""
|
|
412
|
+
results = [scan_repo(r, force_remote=force_remote) for r in repos]
|
|
413
|
+
for r in results:
|
|
414
|
+
if r.get("scan_mode") == "remote" or not r.get("policy_link"):
|
|
415
|
+
_add_remote_policy_link(r)
|
|
416
|
+
return results
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
# Display
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
def _compute_policy_summary(reports):
|
|
424
|
+
"""Compute policy sync summary counts from fleet reports."""
|
|
425
|
+
current = stale = needs_action = 0
|
|
426
|
+
for r in reports:
|
|
427
|
+
link = r.get("policy_link", {})
|
|
428
|
+
state = link.get("state", "unknown")
|
|
429
|
+
if state == "synced":
|
|
430
|
+
current += 1
|
|
431
|
+
elif state in ("behind", "diverged"):
|
|
432
|
+
stale += 1
|
|
433
|
+
elif state == "no-cache":
|
|
434
|
+
needs_action += 1
|
|
435
|
+
# cached, local-only, unknown, unavailable — not counted
|
|
436
|
+
return {
|
|
437
|
+
"policy_current": current,
|
|
438
|
+
"policy_stale": stale,
|
|
439
|
+
"policy_needs_action": needs_action,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def print_fleet_report(reports):
|
|
444
|
+
"""Print formatted fleet report."""
|
|
445
|
+
print()
|
|
446
|
+
print(" gator fleet-report")
|
|
447
|
+
print(f" {len(reports)} repos registered")
|
|
448
|
+
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
449
|
+
print()
|
|
450
|
+
|
|
451
|
+
for r in reports:
|
|
452
|
+
name = r["name"]
|
|
453
|
+
|
|
454
|
+
if not r["accessible"]:
|
|
455
|
+
print(f" ✗ {name}")
|
|
456
|
+
error = r.get("error", f"NOT ACCESSIBLE — {r['path']}")
|
|
457
|
+
print(f" {error}")
|
|
458
|
+
print()
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
# Status indicator
|
|
462
|
+
issues = r.get("issues", 0)
|
|
463
|
+
has_trailers = bool(r.get("trailers"))
|
|
464
|
+
scan_mode = r.get("scan_mode", "local")
|
|
465
|
+
indicator = "✓" if issues == 0 else "!"
|
|
466
|
+
mode_tag = " (remote)" if scan_mode == "remote" else ""
|
|
467
|
+
|
|
468
|
+
print(f" {indicator} {name}{mode_tag}")
|
|
469
|
+
|
|
470
|
+
# Git info
|
|
471
|
+
commit = r.get("last_commit")
|
|
472
|
+
if commit and "error" in commit:
|
|
473
|
+
print(f" last commit: {commit['error']}")
|
|
474
|
+
elif commit:
|
|
475
|
+
print(f" last commit: {commit['hash']} {commit['message'][:50]} ({commit['age']})")
|
|
476
|
+
print(f" branch: {r.get('branch', '?')} | tree: {r.get('working_tree', '?')} | commits (30d): {r.get('commits_30d', 0)}")
|
|
477
|
+
|
|
478
|
+
# Governance info
|
|
479
|
+
gen = r.get("generation", 0)
|
|
480
|
+
policy = r.get("policy_version", "—")
|
|
481
|
+
charters = r.get("charters", 0)
|
|
482
|
+
functions = r.get("functions", 0)
|
|
483
|
+
threads = r.get("threads", 0)
|
|
484
|
+
if r.get("hooks_sources") and r.get("hooks_installed"):
|
|
485
|
+
hooks = "yes"
|
|
486
|
+
elif r.get("hooks_installed"):
|
|
487
|
+
hooks = "installed (no sources)"
|
|
488
|
+
elif r.get("hooks_sources"):
|
|
489
|
+
hooks = "sources only (not installed)"
|
|
490
|
+
else:
|
|
491
|
+
hooks = "no"
|
|
492
|
+
|
|
493
|
+
print(f" gen {gen} | policy: {policy} | charters: {charters} ({functions} fn) | threads: {threads}")
|
|
494
|
+
print(f" issues: {issues} | hooks: {hooks}")
|
|
495
|
+
|
|
496
|
+
# Trailers (if present)
|
|
497
|
+
trailers = r.get("trailers", {})
|
|
498
|
+
if trailers:
|
|
499
|
+
trailer_parts = []
|
|
500
|
+
if "Gator-Significance" in trailers:
|
|
501
|
+
trailer_parts.append(f"sig: {trailers['Gator-Significance']}")
|
|
502
|
+
if "Gator-Change-Type" in trailers:
|
|
503
|
+
trailer_parts.append(f"type: {trailers['Gator-Change-Type']}")
|
|
504
|
+
if "Gator-Charter-Changed" in trailers:
|
|
505
|
+
trailer_parts.append(f"charter: {trailers['Gator-Charter-Changed']}")
|
|
506
|
+
if "Gator-Agent" in trailers:
|
|
507
|
+
trailer_parts.append(f"agent: {trailers['Gator-Agent']}")
|
|
508
|
+
if trailer_parts:
|
|
509
|
+
print(f" trailers: {' | '.join(trailer_parts)}")
|
|
510
|
+
|
|
511
|
+
# Mission
|
|
512
|
+
mission = r.get("mission_summary")
|
|
513
|
+
if mission:
|
|
514
|
+
print(f" mission: {mission}")
|
|
515
|
+
|
|
516
|
+
print()
|
|
517
|
+
|
|
518
|
+
# Fleet summary
|
|
519
|
+
accessible = [r for r in reports if r.get("accessible")]
|
|
520
|
+
local_scanned = sum(1 for r in accessible if r.get("scan_mode") == "local")
|
|
521
|
+
remote_scanned = sum(1 for r in accessible if r.get("scan_mode") == "remote")
|
|
522
|
+
total_charters = sum(r.get("charters", 0) for r in accessible)
|
|
523
|
+
total_functions = sum(r.get("functions", 0) for r in accessible)
|
|
524
|
+
total_issues = sum(r.get("issues", 0) for r in accessible)
|
|
525
|
+
with_hooks = sum(1 for r in accessible if r.get("has_hooks"))
|
|
526
|
+
with_trailers = sum(1 for r in accessible if r.get("trailers"))
|
|
527
|
+
|
|
528
|
+
# Policy summary (authoritative repos only)
|
|
529
|
+
policy_counts = _compute_policy_summary(reports)
|
|
530
|
+
|
|
531
|
+
print(f" fleet totals: {total_charters} charters, {total_functions} functions, {total_issues} issues")
|
|
532
|
+
print(f" hooks: {with_hooks}/{len(accessible)} repos | trailers: {with_trailers}/{len(accessible)} repos")
|
|
533
|
+
pc = policy_counts
|
|
534
|
+
print(f" policy: {pc['policy_current']} synced, {pc['policy_stale']} stale, {pc['policy_needs_action']} need cache")
|
|
535
|
+
if remote_scanned:
|
|
536
|
+
print(f" scan: {local_scanned} local, {remote_scanned} remote (thin-fetch)")
|
|
537
|
+
print()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def print_json_report(reports):
|
|
541
|
+
"""Output fleet report as JSON."""
|
|
542
|
+
summary = {
|
|
543
|
+
"total": len(reports),
|
|
544
|
+
"accessible": sum(1 for r in reports if r.get("accessible")),
|
|
545
|
+
"total_charters": sum(r.get("charters", 0) for r in reports),
|
|
546
|
+
"total_functions": sum(r.get("functions", 0) for r in reports),
|
|
547
|
+
"total_issues": sum(r.get("issues", 0) for r in reports),
|
|
548
|
+
"with_hooks": sum(1 for r in reports if r.get("has_hooks")),
|
|
549
|
+
"with_trailers": sum(1 for r in reports if r.get("trailers")),
|
|
550
|
+
}
|
|
551
|
+
summary.update(_compute_policy_summary(reports))
|
|
552
|
+
output = {
|
|
553
|
+
"version": VERSION,
|
|
554
|
+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
555
|
+
"repos": reports,
|
|
556
|
+
"summary": summary,
|
|
557
|
+
}
|
|
558
|
+
print(json.dumps(output, indent=2))
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
# Entry point
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
def main():
|
|
566
|
+
ensure_utf8_stdout()
|
|
567
|
+
|
|
568
|
+
parser = argparse.ArgumentParser(
|
|
569
|
+
description="Gator fleet-report — cross-repo governance status."
|
|
570
|
+
)
|
|
571
|
+
parser.add_argument(
|
|
572
|
+
"--json", "-j",
|
|
573
|
+
action="store_true",
|
|
574
|
+
help="Output as JSON",
|
|
575
|
+
)
|
|
576
|
+
parser.add_argument(
|
|
577
|
+
"--repo", "-r",
|
|
578
|
+
help="Report on a single repo by name",
|
|
579
|
+
)
|
|
580
|
+
parser.add_argument(
|
|
581
|
+
"--remote",
|
|
582
|
+
action="store_true",
|
|
583
|
+
help="Force remote scanning for all repos (thin-fetch via bare cache)",
|
|
584
|
+
)
|
|
585
|
+
args = parser.parse_args()
|
|
586
|
+
|
|
587
|
+
command_post = find_command_post()
|
|
588
|
+
if not command_post:
|
|
589
|
+
print(" Error: not in a gator-command repo.", file=sys.stderr)
|
|
590
|
+
sys.exit(1)
|
|
591
|
+
|
|
592
|
+
repos = parse_registry(command_post)
|
|
593
|
+
if not repos:
|
|
594
|
+
print(" No repos in registry.", file=sys.stderr)
|
|
595
|
+
sys.exit(1)
|
|
596
|
+
|
|
597
|
+
# Filter to single repo if requested
|
|
598
|
+
if args.repo:
|
|
599
|
+
repos = [r for r in repos if r["name"] == args.repo]
|
|
600
|
+
if not repos:
|
|
601
|
+
print(f" Error: repo '{args.repo}' not found in registry.", file=sys.stderr)
|
|
602
|
+
sys.exit(1)
|
|
603
|
+
|
|
604
|
+
# Scan
|
|
605
|
+
reports = scan_fleet(repos, force_remote=args.remote)
|
|
606
|
+
|
|
607
|
+
# Output
|
|
608
|
+
if args.json:
|
|
609
|
+
print_json_report(reports)
|
|
610
|
+
else:
|
|
611
|
+
print_fleet_report(reports)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
if __name__ == "__main__":
|
|
615
|
+
main()
|