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,460 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ gator fleet-intel — generate per-repo intelligence summaries for the command post.
4
+
5
+ Reads fleet state (git history, .gator/ files, committed summaries, trailers)
6
+ and produces structured intelligence profiles in gator-command/threads/.
7
+
8
+ Usage:
9
+ python gator-fleet-intel.py # update all repo profiles
10
+ python gator-fleet-intel.py --repo NAME # update one repo
11
+ python gator-fleet-intel.py --json # JSON output instead of files
12
+ python gator-fleet-intel.py --dry-run # show what would be written
13
+
14
+ This script synthesizes, it does not invent. Every field is derived from
15
+ observable git/file state — no LLM inference, no fabrication.
16
+
17
+ **Limitation**: currently requires local repo checkouts. Remote-only repos
18
+ (accessible via thin-fetch / bare cache) are skipped. Future: add remote
19
+ profiling via gator_remote.py to match fleet-report's remote capability.
20
+
21
+ @reads: gator-command/registry.md, .gator/ in each repo, git history,
22
+ .gator/sessions/*.md (committed summaries)
23
+ @writes: gator-command/threads/repo-*.md
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+ import subprocess
30
+ import sys
31
+ from datetime import datetime, timezone, timedelta
32
+ from pathlib import Path
33
+
34
+ SCRIPTS_DIR = Path(__file__).resolve().parent
35
+ sys.path.insert(0, str(SCRIPTS_DIR))
36
+
37
+ from gator_core import (
38
+ get_version, find_command_post, normalize_path, parse_registry,
39
+ git, ensure_utf8_stdout,
40
+ )
41
+
42
+ VERSION = get_version()
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Data collection
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def get_recent_commits(repo_path, days=30, limit=20):
50
+ """Get recent commit messages with dates and trailers."""
51
+ since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
52
+ log, ok = git(
53
+ "log", "--format=%H|%s|%ci|%(trailers:key=Gator-Change-Type,valueonly)",
54
+ f"--since={since}", f"-{limit}", "dev",
55
+ cwd=repo_path,
56
+ )
57
+ if not ok:
58
+ log, ok = git(
59
+ "log", "--format=%H|%s|%ci|%(trailers:key=Gator-Change-Type,valueonly)",
60
+ f"--since={since}", f"-{limit}",
61
+ cwd=repo_path,
62
+ )
63
+ if not ok:
64
+ return []
65
+
66
+ commits = []
67
+ for line in log.strip().splitlines():
68
+ if not line.strip():
69
+ continue
70
+ parts = line.split("|", 3)
71
+ if len(parts) >= 3:
72
+ commits.append({
73
+ "hash": parts[0][:8],
74
+ "message": parts[1].strip(),
75
+ "date": parts[2].strip()[:10],
76
+ "change_type": parts[3].strip() if len(parts) > 3 else "",
77
+ })
78
+ return commits
79
+
80
+
81
+ def get_commit_frequency(repo_path):
82
+ """Compute commit frequency stats."""
83
+ counts = {}
84
+ for days_label, days in [("7d", 7), ("30d", 30)]:
85
+ since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
86
+ out, ok = git("rev-list", "--count", f"--since={since}", "HEAD", cwd=repo_path)
87
+ counts[days_label] = int(out.strip()) if ok and out.strip().isdigit() else 0
88
+ return counts
89
+
90
+
91
+ def get_change_type_distribution(commits):
92
+ """Count change types from trailer data."""
93
+ types = {}
94
+ for c in commits:
95
+ ct = c.get("change_type", "").strip()
96
+ if ct:
97
+ types[ct] = types.get(ct, 0) + 1
98
+ return types
99
+
100
+
101
+ def read_mission(repo_path):
102
+ """Read mission summary from .gator/mission.md."""
103
+ for candidate in [
104
+ repo_path / ".gator" / "mission.md",
105
+ repo_path / "gator-command" / "mission.md",
106
+ ]:
107
+ if candidate.exists():
108
+ text = candidate.read_text(encoding="utf-8", errors="replace")
109
+ lines = []
110
+ for line in text.splitlines():
111
+ stripped = line.strip()
112
+ if stripped and not stripped.startswith("#") and not stripped.startswith("["):
113
+ lines.append(stripped)
114
+ if len(lines) >= 3:
115
+ break
116
+ return " ".join(lines)[:300] if lines else ""
117
+ return ""
118
+
119
+
120
+ def read_charter_names(repo_path):
121
+ """Read charter file names (minus templates/index)."""
122
+ charters_dir = repo_path / ".gator" / "charters"
123
+ if not charters_dir.is_dir():
124
+ return []
125
+ skip = {"_template.md", "README.md", "INDEX.md"}
126
+ return sorted(f.stem for f in charters_dir.glob("*.md") if f.name not in skip)
127
+
128
+
129
+ def read_active_threads(repo_path):
130
+ """Read active thread names from .gator/active-threads/."""
131
+ threads_dir = repo_path / ".gator" / "active-threads"
132
+ if not threads_dir.is_dir():
133
+ return []
134
+ return sorted(f.stem for f in threads_dir.glob("*.md"))
135
+
136
+
137
+ def read_issues_count(repo_path):
138
+ """Count open issues from .gator/issues.md."""
139
+ issues_file = repo_path / ".gator" / "issues.md"
140
+ if not issues_file.exists():
141
+ return 0
142
+ text = issues_file.read_text(encoding="utf-8", errors="replace")
143
+ return text.lower().count("**status**: open")
144
+
145
+
146
+ def read_committed_decisions(repo_path, days=30):
147
+ """Read decisions from committed session summaries."""
148
+ sessions_dir = repo_path / ".gator" / "sessions"
149
+ if not sessions_dir.is_dir():
150
+ return []
151
+
152
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
153
+ decisions = []
154
+
155
+ for f in sorted(sessions_dir.glob("*.md"), reverse=True):
156
+ if f.name.startswith("_"):
157
+ continue
158
+ if len(f.name) >= 10 and f.name[:10] < cutoff:
159
+ continue
160
+
161
+ try:
162
+ text = f.read_text(encoding="utf-8", errors="replace")
163
+ except OSError:
164
+ continue
165
+
166
+ in_decisions = False
167
+ for line in text.splitlines():
168
+ stripped = line.strip()
169
+ if stripped == "## Decisions":
170
+ in_decisions = True
171
+ continue
172
+ if stripped.startswith("## ") and in_decisions:
173
+ break
174
+ if in_decisions and stripped.startswith("- "):
175
+ # Strip timestamp prefix
176
+ m = re.match(r'^- \[([^\]]*)\]\s*(.*)', stripped)
177
+ text_part = m.group(2) if m else stripped[2:]
178
+ if text_part and text_part != "*No decisions extracted*":
179
+ decisions.append(text_part)
180
+
181
+ return decisions[:20] # cap at 20 most recent
182
+
183
+
184
+ def read_outbox(repo_path):
185
+ """Read outbox items (cross-repo observations)."""
186
+ outbox = repo_path / ".gator" / "outbox.md"
187
+ if not outbox.exists():
188
+ return []
189
+ text = outbox.read_text(encoding="utf-8", errors="replace")
190
+ items = []
191
+ for line in text.splitlines():
192
+ stripped = line.strip()
193
+ if stripped.startswith("- ") and stripped != "- *(empty)*":
194
+ items.append(stripped[2:])
195
+ return items
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Profile generation
200
+ # ---------------------------------------------------------------------------
201
+
202
+ def build_profile(name, repo_path):
203
+ """Build a complete intelligence profile for a repo."""
204
+ path = Path(normalize_path(str(repo_path)))
205
+
206
+ if not path.is_dir():
207
+ return {
208
+ "name": name,
209
+ "accessible": False,
210
+ "path": str(repo_path),
211
+ }
212
+
213
+ commits = get_recent_commits(path)
214
+ freq = get_commit_frequency(path)
215
+ change_types = get_change_type_distribution(commits)
216
+ mission = read_mission(path)
217
+ charters = read_charter_names(path)
218
+ threads = read_active_threads(path)
219
+ open_issues = read_issues_count(path)
220
+ decisions = read_committed_decisions(path)
221
+ outbox = read_outbox(path)
222
+
223
+ # Derive activity level
224
+ c7 = freq.get("7d", 0)
225
+ if c7 >= 10:
226
+ activity = "high"
227
+ elif c7 >= 3:
228
+ activity = "moderate"
229
+ elif c7 >= 1:
230
+ activity = "low"
231
+ else:
232
+ activity = "dormant"
233
+
234
+ # Recent commit themes — group by message prefix patterns
235
+ themes = {}
236
+ for c in commits[:15]:
237
+ msg = c["message"]
238
+ # Extract first word/prefix as rough theme
239
+ prefix = msg.split(":")[0].split("(")[0].strip() if ":" in msg or "(" in msg else msg.split()[0] if msg else ""
240
+ prefix = prefix.lower()
241
+ if prefix and len(prefix) < 30:
242
+ themes[prefix] = themes.get(prefix, 0) + 1
243
+
244
+ # Top themes by frequency
245
+ top_themes = sorted(themes.items(), key=lambda x: -x[1])[:5]
246
+
247
+ return {
248
+ "name": name,
249
+ "accessible": True,
250
+ "path": str(path),
251
+ "mission": mission,
252
+ "activity": activity,
253
+ "commits_7d": freq.get("7d", 0),
254
+ "commits_30d": freq.get("30d", 0),
255
+ "charters": charters,
256
+ "threads": threads,
257
+ "open_issues": open_issues,
258
+ "change_types": change_types,
259
+ "commit_themes": top_themes,
260
+ "recent_commits": [
261
+ {"hash": c["hash"], "message": c["message"], "date": c["date"]}
262
+ for c in commits[:10]
263
+ ],
264
+ "decisions": decisions[:10],
265
+ "outbox": outbox,
266
+ "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
267
+ }
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Output: markdown thread
272
+ # ---------------------------------------------------------------------------
273
+
274
+ def render_thread(profile):
275
+ """Render a per-repo intelligence profile as markdown thread."""
276
+ name = profile["name"]
277
+ lines = []
278
+
279
+ lines.append("---")
280
+ lines.append(f"title: \"{name}\"")
281
+ lines.append("category: repo-profile")
282
+ lines.append(f"generated: {profile.get('generated_at', '')[:10]}")
283
+ lines.append(f"repo-path: {profile.get('path', '')}")
284
+ lines.append("---")
285
+ lines.append("")
286
+ lines.append(f"# {name}")
287
+ lines.append("")
288
+
289
+ # What it is
290
+ lines.append("## What It Is")
291
+ lines.append("")
292
+ mission = profile.get("mission", "")
293
+ lines.append(mission if mission else "*No mission.md found*")
294
+ lines.append("")
295
+
296
+ # Current state
297
+ lines.append("## Current State")
298
+ lines.append("")
299
+ activity = profile.get("activity", "unknown")
300
+ c7 = profile.get("commits_7d", 0)
301
+ c30 = profile.get("commits_30d", 0)
302
+ lines.append(f"**Activity**: {activity} ({c7} commits in 7d, {c30} in 30d)")
303
+ lines.append("")
304
+
305
+ if profile.get("open_issues"):
306
+ lines.append(f"**Open issues**: {profile['open_issues']}")
307
+ lines.append("")
308
+
309
+ # Charter coverage
310
+ charters = profile.get("charters", [])
311
+ if charters:
312
+ lines.append(f"**Charters**: {', '.join(charters)}")
313
+ lines.append("")
314
+
315
+ threads = profile.get("threads", [])
316
+ if threads:
317
+ lines.append(f"**Active threads**: {', '.join(threads)}")
318
+ lines.append("")
319
+
320
+ # What changed recently
321
+ lines.append("## Recent Activity")
322
+ lines.append("")
323
+
324
+ change_types = profile.get("change_types", {})
325
+ if change_types:
326
+ ct_parts = [f"{k} ({v})" for k, v in sorted(change_types.items(), key=lambda x: -x[1])]
327
+ lines.append(f"**Change types**: {', '.join(ct_parts)}")
328
+ lines.append("")
329
+
330
+ commit_themes = profile.get("commit_themes", [])
331
+ if commit_themes:
332
+ theme_parts = [f"{t[0]} ({t[1]})" for t in commit_themes]
333
+ lines.append(f"**Commit themes**: {', '.join(theme_parts)}")
334
+ lines.append("")
335
+
336
+ recent = profile.get("recent_commits", [])
337
+ if recent:
338
+ for c in recent[:7]:
339
+ lines.append(f"- `{c['hash']}` {c['date']} — {c['message']}")
340
+ if len(recent) > 7:
341
+ lines.append(f"- ... and {len(recent) - 7} more")
342
+ lines.append("")
343
+
344
+ # Decisions
345
+ decisions = profile.get("decisions", [])
346
+ if decisions:
347
+ lines.append("## Recent Decisions")
348
+ lines.append("")
349
+ for d in decisions[:7]:
350
+ lines.append(f"- {d}")
351
+ if len(decisions) > 7:
352
+ lines.append(f"- ... and {len(decisions) - 7} more")
353
+ lines.append("")
354
+
355
+ # Outbox
356
+ outbox = profile.get("outbox", [])
357
+ if outbox:
358
+ lines.append("## Outbox Items")
359
+ lines.append("")
360
+ for item in outbox:
361
+ lines.append(f"- {item}")
362
+ lines.append("")
363
+
364
+ # Footer
365
+ lines.append("---")
366
+ lines.append("")
367
+ lines.append(f"*Generated by gator fleet-intel {VERSION} on {profile.get('generated_at', '')[:10]}. "
368
+ f"Do not edit manually — regenerated on each run.*")
369
+ lines.append("")
370
+
371
+ return "\n".join(lines)
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Main
376
+ # ---------------------------------------------------------------------------
377
+
378
+ def main():
379
+ ensure_utf8_stdout()
380
+
381
+ parser = argparse.ArgumentParser(
382
+ description="Generate per-repo intelligence summaries for the command post."
383
+ )
384
+ parser.add_argument("--repo", metavar="NAME", help="Update one repo only")
385
+ parser.add_argument("--json", "-j", action="store_true", help="JSON output to stdout")
386
+ parser.add_argument("--dry-run", action="store_true", help="Show what would be written")
387
+
388
+ args = parser.parse_args()
389
+
390
+ # Find command post
391
+ cp = find_command_post()
392
+ if not cp:
393
+ print(" ! Could not find gator-command/ directory")
394
+ sys.exit(1)
395
+
396
+ gc_dir = cp / "gator-command"
397
+ registry = parse_registry(cp)
398
+
399
+ if not registry:
400
+ print(" No repos in registry.")
401
+ return
402
+
403
+ # Filter to single repo if requested
404
+ if args.repo:
405
+ registry = [r for r in registry if r["name"] == args.repo]
406
+ if not registry:
407
+ print(f" ! Repo '{args.repo}' not found in registry")
408
+ sys.exit(1)
409
+
410
+ # In JSON mode, status goes to stderr
411
+ log = (lambda msg: print(msg, file=sys.stderr)) if args.json else print
412
+
413
+ log("")
414
+ log(f" fleet-intel: profiling {len(registry)} repos")
415
+ log("")
416
+
417
+ profiles = []
418
+ for entry in registry:
419
+ name = entry["name"]
420
+ repo_path = entry["path"]
421
+ log(f" scanning {name} ...")
422
+ profile = build_profile(name, repo_path)
423
+ profiles.append(profile)
424
+
425
+ log("")
426
+
427
+ if args.json:
428
+ print(json.dumps(profiles, indent=2, default=str))
429
+ return
430
+
431
+ # Write thread files
432
+ threads_dir = gc_dir / "threads"
433
+ threads_dir.mkdir(exist_ok=True)
434
+
435
+ written = 0
436
+ for profile in profiles:
437
+ if not profile.get("accessible"):
438
+ print(f" ! {profile['name']}: inaccessible, skipped")
439
+ continue
440
+
441
+ filename = f"repo-{profile['name']}.md"
442
+ filepath = threads_dir / filename
443
+ content = render_thread(profile)
444
+
445
+ if args.dry_run:
446
+ print(f" would write: {filepath}")
447
+ print(f" {len(content)} chars, {content.count(chr(10))} lines")
448
+ else:
449
+ filepath.write_text(content, encoding="utf-8")
450
+ print(f" ✓ {filename}")
451
+ written += 1
452
+
453
+ print()
454
+ if not args.dry_run:
455
+ print(f" Written: {written} repo profiles to {threads_dir}")
456
+ print()
457
+
458
+
459
+ if __name__ == "__main__":
460
+ main()