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,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()