gator-command 1.0.0__py3-none-any.whl

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