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,780 @@
1
+ #!/usr/bin/env python3
2
+ """Graph-health analysis for the Memex thread graph.
3
+
4
+ Reports:
5
+ - Backlink index (who links to each thread)
6
+ - 3-hop reachability violations
7
+ - Single-bridge edges (removal disconnects the graph)
8
+ - Graph visualization (PNG)
9
+
10
+ Usage:
11
+ python gator-command/scripts/graph_health.py
12
+ python gator-command/scripts/graph_health.py --image docs/wiki/thread-graph.png
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import io
19
+ import datetime as dt
20
+ import json
21
+ import sys
22
+ from collections import defaultdict
23
+ from pathlib import Path
24
+
25
+ import networkx as nx
26
+
27
+ # Re-use the existing thread/link infrastructure
28
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
29
+ from generate_wiki import extract_links, load_threads
30
+
31
+ RED = "\033[0;31m"
32
+ YELLOW = "\033[0;33m"
33
+ GREEN = "\033[0;32m"
34
+ CYAN = "\033[0;36m"
35
+ NC = "\033[0m"
36
+
37
+
38
+ def error(msg: str) -> None:
39
+ print(f"{RED}ERROR:{NC} {msg}")
40
+
41
+
42
+ def warn(msg: str) -> None:
43
+ print(f"{YELLOW}WARN:{NC} {msg}")
44
+
45
+
46
+ def ok(msg: str) -> None:
47
+ print(f"{GREEN}OK:{NC} {msg}")
48
+
49
+
50
+ def info(msg: str) -> None:
51
+ print(f"{CYAN}INFO:{NC} {msg}")
52
+
53
+
54
+ def build_graph(memex_dir: Path, graph_filter: str | None = None) -> tuple[nx.DiGraph, dict[Path, str]]:
55
+ """Build a directed graph from thread cross-references.
56
+
57
+ If graph_filter is set, only include threads whose `graph:` frontmatter
58
+ matches the filter. Cross-graph links to excluded threads are dropped.
59
+ If graph_filter is None, all threads are included (whole-graph analysis).
60
+ """
61
+ threads = load_threads(memex_dir)
62
+
63
+ # Build path-to-graph mapping for all threads
64
+ path_to_graph: dict[Path, str] = {}
65
+ for thread in threads:
66
+ resolved = thread.source_path.resolve()
67
+ path_to_graph[resolved] = thread.graph
68
+
69
+ # Filter threads if requested
70
+ if graph_filter is not None:
71
+ threads = [t for t in threads if t.graph == graph_filter]
72
+
73
+ G = nx.DiGraph()
74
+ path_to_title: dict[Path, str] = {}
75
+
76
+ for thread in threads:
77
+ resolved = thread.source_path.resolve()
78
+ path_to_title[resolved] = thread.title
79
+ G.add_node(resolved, title=thread.title, tier=thread.tier_label,
80
+ graph=thread.graph)
81
+
82
+ for thread in threads:
83
+ source = thread.source_path.resolve()
84
+ links = extract_links(thread.connection_lines, thread.source_path)
85
+ for link in links:
86
+ if link.target_path and link.target_path in path_to_title:
87
+ G.add_edge(source, link.target_path, annotation=link.annotation)
88
+
89
+ return G, path_to_title
90
+
91
+
92
+ def report_backlinks(G: nx.DiGraph, path_to_title: dict[Path, str]) -> int:
93
+ """Report inbound links for each thread. Returns orphan count as errors."""
94
+ print("-- Backlink Index --")
95
+ errors = 0
96
+ backlinks: dict[Path, list[Path]] = defaultdict(list)
97
+ for source, target in G.edges():
98
+ backlinks[target].append(source)
99
+
100
+ for node in sorted(G.nodes(), key=lambda n: path_to_title.get(n, "")):
101
+ title = path_to_title[node]
102
+ inbound = backlinks.get(node, [])
103
+ if inbound:
104
+ sources = ", ".join(path_to_title[s] for s in inbound)
105
+ info(f"{title} <- {sources}")
106
+ else:
107
+ error(f"{title} <- (no inbound links — orphan)")
108
+ errors += 1
109
+ print()
110
+ return errors
111
+
112
+
113
+ def report_reachability(G: nx.DiGraph, path_to_title: dict[Path, str], health: dict, max_hops: int = 3) -> int:
114
+ """Report reachability, split into intra-cluster (errors) and cross-cluster (info)."""
115
+ print(f"-- {max_hops}-Hop Navigability (cluster-aware) --")
116
+ errors = 0
117
+
118
+ U = G.to_undirected()
119
+
120
+ if U.number_of_nodes() == 0:
121
+ ok("No threads to check")
122
+ print()
123
+ return 0
124
+
125
+ if not nx.is_connected(U):
126
+ components = list(nx.connected_components(U))
127
+ error(f"Graph is disconnected: {len(components)} components")
128
+ for i, comp in enumerate(components):
129
+ titles = sorted(path_to_title[n] for n in comp)
130
+ info(f" Component {i + 1}: {', '.join(titles)}")
131
+ errors += 1
132
+ print()
133
+ return errors
134
+
135
+ intra = health.get("intra_hop_violations", [])
136
+ cross = health.get("cross_hop_violations", [])
137
+
138
+ if intra:
139
+ error(f"{len(intra)} intra-cluster pair(s) exceed {max_hops}-hop limit:")
140
+ for v in intra:
141
+ warn(f" {v['a']} <-> {v['b']} ({v['hops']} hops)")
142
+ errors += len(intra)
143
+ else:
144
+ ok(f"All intra-cluster pairs reachable within {max_hops} hops")
145
+
146
+ if cross:
147
+ info(f"{len(cross)} cross-cluster pair(s) exceed {max_hops} hops (penalized at 0.25x weight):")
148
+ for v in cross[:10]:
149
+ info(f" {v['a']} <-> {v['b']} ({v['hops']} hops)")
150
+ if len(cross) > 10:
151
+ info(f" ... and {len(cross) - 10} more")
152
+ else:
153
+ ok(f"All cross-cluster pairs also within {max_hops} hops")
154
+
155
+ print()
156
+ return errors
157
+
158
+
159
+ def report_bridges(G: nx.DiGraph, path_to_title: dict[Path, str]) -> int:
160
+ """Report single-bridge edges whose removal disconnects the graph."""
161
+ print("-- Single-Bridge Detection --")
162
+ errors = 0
163
+ U = G.to_undirected()
164
+
165
+ if U.number_of_nodes() < 2:
166
+ ok("Too few threads for bridge detection")
167
+ print()
168
+ return 0
169
+
170
+ bridges = list(nx.bridges(U))
171
+ if bridges:
172
+ warn(f"{len(bridges)} bridge edge(s) found (removal would disconnect the graph):")
173
+ for u, v in bridges:
174
+ warn(f" {path_to_title[u]} <-> {path_to_title[v]}")
175
+ errors += len(bridges)
176
+ else:
177
+ ok("No bridge edges — graph is 2-edge-connected")
178
+ print()
179
+ return errors
180
+
181
+
182
+ def report_clusters(health: dict) -> None:
183
+ """Report detected clusters."""
184
+ print("-- Cluster Detection (Louvain) --")
185
+ clusters = health.get("cluster_summary", [])
186
+ if not clusters:
187
+ info("No clusters detected")
188
+ print()
189
+ return
190
+ info(f"{len(clusters)} cluster(s) detected")
191
+ for c in clusters:
192
+ print(f" Cluster {c['id'] + 1} ({c['size']} nodes):")
193
+ for name in c["members"]:
194
+ print(f" - {name}")
195
+ print()
196
+
197
+
198
+ def report_efficiency(health: dict) -> int:
199
+ """Report edge redundancy / efficiency."""
200
+ print("-- Edge Efficiency --")
201
+ errors = 0
202
+ ratio = health.get("redundancy_ratio", 0)
203
+ redundant = health.get("redundant_edges", 0)
204
+ total = health.get("total_undirected_edges", 0)
205
+
206
+ if total == 0:
207
+ ok("No edges to evaluate")
208
+ print()
209
+ return 0
210
+
211
+ # Finding 4 fix: report branches must match scoring branches
212
+ if ratio < 0.30:
213
+ warn(f"{redundant}/{total} edges redundant ({ratio:.0%}) — too sparse, fragile tree structure")
214
+ errors += 1
215
+ elif ratio <= 0.60:
216
+ ok(f"{redundant}/{total} edges redundant ({ratio:.0%}) — within sweet spot")
217
+ else:
218
+ warn(f"{redundant}/{total} edges redundant ({ratio:.0%}) — over-linked, {redundant - int(total * 0.60)} edges are noise")
219
+ errors += 1
220
+ info(f"Sweet spot: 30-60% redundancy. Below 30% = fragile tree. Above 60% = noisy.")
221
+ print()
222
+ return errors
223
+
224
+
225
+ def report_legibility(health: dict) -> int:
226
+ """Report peripheral node access to high-betweenness hubs."""
227
+ print("-- Legibility (peripheral access to hubs) --")
228
+ errors = 0
229
+ peripherals = health.get("peripherals", 0)
230
+ stranded = health.get("peripherals_stranded", [])
231
+ hubs = health.get("hubs", [])
232
+
233
+ if peripherals == 0:
234
+ ok("No peripheral nodes (degree <= 3)")
235
+ print()
236
+ return 0
237
+
238
+ info(f"Hubs (top 20% betweenness): {', '.join(hubs)}")
239
+ reached = peripherals - len(stranded)
240
+ if stranded:
241
+ warn(f"{reached}/{peripherals} peripheral nodes reach a hub within 2 hops")
242
+ for name in stranded:
243
+ warn(f" Stranded: {name}")
244
+ errors += len(stranded)
245
+ else:
246
+ ok(f"All {peripherals} peripheral nodes reach a hub within 2 hops")
247
+ print()
248
+ return errors
249
+
250
+
251
+ def detect_clusters(U: nx.Graph, path_to_title: dict[Path, str]) -> list[set]:
252
+ """Detect natural clusters using Louvain community detection."""
253
+ if U.number_of_nodes() == 0:
254
+ return []
255
+ try:
256
+ from networkx.algorithms.community import louvain_communities
257
+ return list(louvain_communities(U, seed=42))
258
+ except ImportError:
259
+ # Fallback: treat entire graph as one cluster
260
+ return [set(U.nodes())]
261
+
262
+
263
+ def compute_redundancy(U: nx.Graph) -> tuple[int, int]:
264
+ """Count redundant edges (endpoints within 2 hops even without the edge).
265
+
266
+ Returns (redundant_count, total_undirected_edges).
267
+ """
268
+ redundant = 0
269
+ total = U.number_of_edges()
270
+ for u, v in list(U.edges()):
271
+ H = U.copy()
272
+ H.remove_edge(u, v)
273
+ if nx.has_path(H, u, v) and nx.shortest_path_length(H, u, v) <= 2:
274
+ redundant += 1
275
+ return redundant, total
276
+
277
+
278
+ def compute_health(G: nx.DiGraph, path_to_title: dict[Path, str], max_hops: int = 3) -> dict:
279
+ """Compute graph stats and a health score.
280
+
281
+ Model v2.1 — subway-informed, cluster-aware (post Codex review):
282
+ - Navigability: intra-cluster 3-hop (full weight) + cross-cluster (0.25 weight)
283
+ - Resilience: bridge edges (unchanged)
284
+ - Connectivity: intra-cluster orphans (20 pts) + global-only orphans (10 pts)
285
+ - Efficiency: edge redundancy ratio (sweet spot 30-60%)
286
+ - Legibility: peripheral nodes' access to high-betweenness hubs
287
+ """
288
+ n_nodes = G.number_of_nodes()
289
+ n_edges = G.number_of_edges()
290
+ U = G.to_undirected()
291
+
292
+ # Basic stats
293
+ avg_degree = (2 * U.number_of_edges() / n_nodes) if n_nodes > 0 else 0
294
+ components = nx.number_connected_components(U) if n_nodes > 0 else 0
295
+
296
+ # Cluster detection
297
+ clusters = detect_clusters(U, path_to_title)
298
+ n_clusters = len(clusters)
299
+ node_to_cluster: dict[Path, int] = {}
300
+ for i, comm in enumerate(clusters):
301
+ for n in comm:
302
+ node_to_cluster[n] = i
303
+ cluster_summary = []
304
+ for i, comm in enumerate(clusters):
305
+ names = sorted(path_to_title.get(n, "?") for n in comm)
306
+ cluster_summary.append({"id": i, "size": len(comm), "members": names})
307
+
308
+ # Bridges
309
+ bridges = list(nx.bridges(U)) if n_nodes >= 2 else []
310
+
311
+ # --- NAVIGABILITY: cluster-aware 3-hop reachability ---
312
+ # Finding 5 fix: compute all-pairs distances once, outside the loop
313
+ intra_violations = []
314
+ cross_violations = []
315
+ all_distances: dict[Path, dict[Path, int]] = {}
316
+ if n_nodes > 0 and nx.is_connected(U):
317
+ all_distances = dict(nx.all_pairs_shortest_path_length(U))
318
+ nodes = list(G.nodes())
319
+ seen_pairs = set()
320
+ for source in nodes:
321
+ lengths = nx.single_source_shortest_path_length(U, source, cutoff=max_hops)
322
+ for target in nodes:
323
+ if target != source and target not in lengths:
324
+ pair = tuple(sorted([source, target], key=lambda n: path_to_title.get(n, "")))
325
+ if pair not in seen_pairs:
326
+ seen_pairs.add(pair)
327
+ actual_dist = all_distances[source].get(target, float("inf"))
328
+ entry = {
329
+ "a": path_to_title[pair[0]],
330
+ "b": path_to_title[pair[1]],
331
+ "hops": actual_dist,
332
+ }
333
+ if node_to_cluster.get(source) == node_to_cluster.get(target):
334
+ intra_violations.append(entry)
335
+ else:
336
+ cross_violations.append(entry)
337
+
338
+ # --- CONNECTIVITY: per-cluster orphan detection ---
339
+ inbound_counts = defaultdict(int)
340
+ for _, target in G.edges():
341
+ inbound_counts[target] += 1
342
+ # Global orphans (no inbound from anywhere)
343
+ global_orphans = [path_to_title[n] for n in G.nodes() if inbound_counts.get(n, 0) == 0]
344
+ # Intra-cluster orphans (no inbound from within own cluster)
345
+ intra_cluster_orphans = []
346
+ for node in G.nodes():
347
+ cluster_id = node_to_cluster.get(node)
348
+ cluster_members = clusters[cluster_id] if cluster_id is not None else set()
349
+ has_intra_inbound = False
350
+ for source, target in G.edges():
351
+ if target == node and source in cluster_members and source != node:
352
+ has_intra_inbound = True
353
+ break
354
+ if not has_intra_inbound:
355
+ intra_cluster_orphans.append(path_to_title[node])
356
+
357
+ # --- EFFICIENCY: edge redundancy ratio ---
358
+ redundant_count, total_undirected = compute_redundancy(U)
359
+ redundancy_ratio = (redundant_count / total_undirected) if total_undirected > 0 else 0.0
360
+
361
+ # --- LEGIBILITY: peripheral access to hubs ---
362
+ # Peripheral = degree <= 3. Hub = top 20% by betweenness centrality.
363
+ # Score: fraction of peripherals that reach a hub within 2 hops.
364
+ bc = nx.betweenness_centrality(U) if n_nodes > 0 else {}
365
+ if bc:
366
+ bc_threshold = sorted(bc.values(), reverse=True)[max(0, n_nodes // 5 - 1)] if n_nodes >= 5 else 0
367
+ hubs = {n for n, v in bc.items() if v >= bc_threshold and v > 0}
368
+ else:
369
+ hubs = set()
370
+ # If no nodes have positive betweenness (e.g., complete graphs where all paths
371
+ # are length 1), fall back to top 20% by degree. A fully connected graph has no
372
+ # stranded peripherals — legibility should be 100, not 0.
373
+ if not hubs and n_nodes > 0:
374
+ degree_sorted = sorted(U.degree(), key=lambda x: -x[1])
375
+ top_count = max(1, n_nodes // 5)
376
+ hubs = {n for n, _ in degree_sorted[:top_count]}
377
+ peripherals = [n for n in U.nodes() if U.degree(n) <= 3]
378
+ peripherals_with_hub_access = 0
379
+ peripherals_stranded = []
380
+ for p in peripherals:
381
+ lengths = nx.single_source_shortest_path_length(U, p, cutoff=2)
382
+ if any(h in lengths for h in hubs):
383
+ peripherals_with_hub_access += 1
384
+ else:
385
+ peripherals_stranded.append(path_to_title[p])
386
+
387
+ # ===== SCORING =====
388
+
389
+ # Navigability (Finding 1 fix: soft cross-cluster penalty, not zero-cost)
390
+ # Intra-cluster violations are full-weight errors (break local navigation).
391
+ # Cross-cluster violations get a soft penalty: each one costs 1/4 what an intra-cluster
392
+ # violation costs. This prevents cluster growth from hiding long paths for free.
393
+ if n_nodes <= 1:
394
+ navigability_score = 100
395
+ elif components > 1:
396
+ navigability_score = 0
397
+ else:
398
+ total_pairs = n_nodes * (n_nodes - 1) // 2
399
+ # Weighted violation count: intra = 1.0, cross = 0.25
400
+ weighted_violations = len(intra_violations) + len(cross_violations) * 0.25
401
+ navigability_score = max(0, int(100 * (1 - weighted_violations / max(total_pairs, 1))))
402
+
403
+ # Resilience: bridges (unchanged)
404
+ if n_edges == 0:
405
+ resilience_score = 100
406
+ elif len(bridges) == 0:
407
+ resilience_score = 100
408
+ else:
409
+ resilience_score = max(0, 100 - (len(bridges) * 30))
410
+
411
+ # Connectivity: per-cluster orphans (Finding 2 fix: use intra_cluster_orphans, not global)
412
+ # A thread with no inbound links from within its own cluster is disconnected from its
413
+ # neighborhood, even if some cross-cluster thread links to it. Both are problems, but
414
+ # intra-cluster orphans are weighted heavier (they break local navigability).
415
+ if n_nodes == 0:
416
+ connectivity_score = 100
417
+ else:
418
+ # Intra-cluster orphans: 20 points each (breaks local navigation)
419
+ # Global orphans that aren't intra-cluster orphans: 10 points each (less severe)
420
+ intra_only = set(intra_cluster_orphans)
421
+ global_only = [o for o in global_orphans if o not in intra_only]
422
+ penalty = len(intra_cluster_orphans) * 20 + len(global_only) * 10
423
+ connectivity_score = max(0, 100 - penalty)
424
+
425
+ # Efficiency: sweet spot is 30-60% redundancy
426
+ # Below 30% = fragile (tree-like). Above 70% = noisy (over-linked).
427
+ # 100 at 30-60%, drops linearly outside that band.
428
+ if total_undirected == 0:
429
+ efficiency_score = 100
430
+ elif redundancy_ratio < 0.30:
431
+ # Too sparse — fragile
432
+ efficiency_score = max(0, int(100 * (redundancy_ratio / 0.30)))
433
+ elif redundancy_ratio <= 0.60:
434
+ # Sweet spot
435
+ efficiency_score = 100
436
+ else:
437
+ # Over-linked — noisy. 100% redundancy = 0 score.
438
+ efficiency_score = max(0, int(100 * (1 - (redundancy_ratio - 0.60) / 0.40)))
439
+
440
+ # Legibility: peripheral access to hubs
441
+ if len(peripherals) == 0:
442
+ legibility_score = 100
443
+ else:
444
+ legibility_score = int(100 * peripherals_with_hub_access / len(peripherals))
445
+
446
+ # Overall: 5 dimensions, equal weight
447
+ overall = (navigability_score + resilience_score + connectivity_score
448
+ + efficiency_score + legibility_score) // 5
449
+ if overall >= 80:
450
+ verdict = "HEALTHY"
451
+ elif overall >= 50:
452
+ verdict = "FAIR"
453
+ else:
454
+ verdict = "UNHEALTHY"
455
+
456
+ return {
457
+ "nodes": n_nodes,
458
+ "edges": n_edges,
459
+ "avg_degree": round(avg_degree, 1),
460
+ "components": components,
461
+ "clusters": n_clusters,
462
+ "cluster_summary": cluster_summary,
463
+ "orphans": global_orphans,
464
+ "intra_cluster_orphans": intra_cluster_orphans,
465
+ "bridges": len(bridges),
466
+ "intra_hop_violations": intra_violations,
467
+ "cross_hop_violations": cross_violations,
468
+ "redundancy_ratio": round(redundancy_ratio, 2),
469
+ "redundant_edges": redundant_count,
470
+ "total_undirected_edges": total_undirected,
471
+ "peripherals": len(peripherals),
472
+ "peripherals_stranded": peripherals_stranded,
473
+ "hubs": sorted(path_to_title.get(h, "?") for h in hubs),
474
+ "navigability_score": navigability_score,
475
+ "resilience_score": resilience_score,
476
+ "connectivity_score": connectivity_score,
477
+ "efficiency_score": efficiency_score,
478
+ "legibility_score": legibility_score,
479
+ "overall": overall,
480
+ "verdict": verdict,
481
+ }
482
+
483
+
484
+ def render_graph_image(
485
+ G: nx.DiGraph, path_to_title: dict[Path, str], output_path: Path, health: dict | None = None
486
+ ) -> None:
487
+ """Render the thread graph as a PNG image with optional health overlay."""
488
+ import matplotlib
489
+
490
+ matplotlib.use("Agg")
491
+ import matplotlib.pyplot as plt
492
+ import matplotlib.patches as mpatches
493
+
494
+ print("-- Graph Visualization --")
495
+
496
+ # Build a labeled graph for display
497
+ labels = {node: title for node, title in path_to_title.items() if node in G}
498
+
499
+ BG = "#f0f0f0"
500
+ TEXT_COLOR = "#222222"
501
+ PANEL_BG = "#ffffff"
502
+ PANEL_EDGE = "#cccccc"
503
+ EDGE_COLOR = "#8888aa"
504
+
505
+ # Color by cluster (from health data) with tier as shape indicator via edge style
506
+ CLUSTER_PALETTE = ["#4A90D9", "#E07B39", "#5BA55B", "#C75B8F", "#8B6FBF", "#C4A132", "#49B6A6"]
507
+ node_to_cluster_map: dict[Path, int] = {}
508
+ if health and "cluster_summary" in health:
509
+ clusters = detect_clusters(G.to_undirected(), path_to_title)
510
+ for i, comm in enumerate(clusters):
511
+ for n in comm:
512
+ node_to_cluster_map[n] = i
513
+ node_colors = [CLUSTER_PALETTE[node_to_cluster_map.get(n, 0) % len(CLUSTER_PALETTE)] for n in G.nodes()]
514
+ # Reference threads get a lighter alpha via edge ring
515
+ node_edge_colors = []
516
+ for n in G.nodes():
517
+ tier = G.nodes[n].get("tier", "")
518
+ if tier == "Reference thread":
519
+ node_edge_colors.append("#888888")
520
+ else:
521
+ node_edge_colors.append("#ffffff")
522
+
523
+ # Use gridspec: graph on top, stats panel below
524
+ if health:
525
+ fig = plt.figure(figsize=(14, 11))
526
+ gs = fig.add_gridspec(2, 1, height_ratios=[4, 1], hspace=0.05)
527
+ ax = fig.add_subplot(gs[0])
528
+ ax_stats = fig.add_subplot(gs[1])
529
+ else:
530
+ fig, ax = plt.subplots(1, 1, figsize=(14, 9))
531
+ ax_stats = None
532
+
533
+ fig.patch.set_facecolor(BG)
534
+ ax.set_facecolor(BG)
535
+
536
+ pos = nx.spring_layout(G, k=2.5, iterations=50, seed=42)
537
+
538
+ # Draw edges
539
+ nx.draw_networkx_edges(
540
+ G, pos, ax=ax,
541
+ edge_color=EDGE_COLOR,
542
+ arrows=True,
543
+ arrowsize=15,
544
+ arrowstyle="-|>",
545
+ connectionstyle="arc3,rad=0.1",
546
+ width=1.5,
547
+ )
548
+
549
+ # Draw nodes
550
+ nx.draw_networkx_nodes(
551
+ G, pos, ax=ax,
552
+ node_color=node_colors,
553
+ node_size=800,
554
+ edgecolors=node_edge_colors,
555
+ linewidths=2.0,
556
+ )
557
+
558
+ # Draw labels
559
+ nx.draw_networkx_labels(
560
+ G, pos, labels=labels, ax=ax,
561
+ font_size=9,
562
+ font_color=TEXT_COLOR,
563
+ font_weight="bold",
564
+ )
565
+
566
+ ax.set_title("Memex Thread Graph", color=TEXT_COLOR, fontsize=14, fontweight="bold", pad=20)
567
+
568
+ # Legend — clusters + tier indicators
569
+ from matplotlib.lines import Line2D
570
+
571
+ legend_elements = []
572
+ if health and "cluster_summary" in health:
573
+ for c in health["cluster_summary"]:
574
+ color = CLUSTER_PALETTE[c["id"] % len(CLUSTER_PALETTE)]
575
+ legend_elements.append(
576
+ Line2D([0], [0], marker="o", color=BG, markerfacecolor=color,
577
+ markersize=10, label=f"Cluster {c['id'] + 1} ({c['size']} nodes)")
578
+ )
579
+ legend_elements.append(
580
+ Line2D([0], [0], marker="o", color=BG, markerfacecolor="#CCCCCC",
581
+ markeredgecolor="#888888", markeredgewidth=2,
582
+ markersize=10, label="Reference thread (gray ring)")
583
+ )
584
+ ax.legend(handles=legend_elements, loc="lower right", facecolor=PANEL_BG,
585
+ edgecolor=PANEL_EDGE, labelcolor=TEXT_COLOR, fontsize=8)
586
+
587
+ ax.axis("off")
588
+
589
+ # Health stats panel below graph
590
+ if health and ax_stats is not None:
591
+ ax_stats.set_facecolor(BG)
592
+ ax_stats.axis("off")
593
+
594
+ verdict = health["verdict"]
595
+ verdict_colors = {"HEALTHY": "#2E7D32", "FAIR": "#F57F17", "UNHEALTHY": "#C62828"}
596
+ verdict_color = verdict_colors.get(verdict, TEXT_COLOR)
597
+
598
+ stats_lines = (
599
+ f"Nodes: {health['nodes']} Edges: {health['edges']} Avg degree: {health['avg_degree']} Clusters: {health['clusters']}\n"
600
+ f"Components: {health['components']} Bridges: {health['bridges']} Orphans: {len(health['orphans'])} Redundancy: {health['redundancy_ratio']:.0%}\n"
601
+ f"\n"
602
+ f"Navigability: {health['navigability_score']} "
603
+ f"Resilience: {health['resilience_score']} "
604
+ f"Connectivity: {health['connectivity_score']} "
605
+ f"Efficiency: {health['efficiency_score']} "
606
+ f"Legibility: {health['legibility_score']}"
607
+ )
608
+
609
+ props = dict(boxstyle="round,pad=0.8", facecolor=PANEL_BG, edgecolor=PANEL_EDGE, alpha=0.95)
610
+
611
+ # Verdict line (colored, bold)
612
+ ax_stats.text(
613
+ 0.5, 0.82,
614
+ f"Health: {verdict} ({health['overall']}/100)",
615
+ transform=ax_stats.transAxes,
616
+ fontsize=12,
617
+ fontfamily="monospace",
618
+ fontweight="bold",
619
+ color=verdict_color,
620
+ verticalalignment="center",
621
+ horizontalalignment="center",
622
+ )
623
+
624
+ # Stats below verdict
625
+ ax_stats.text(
626
+ 0.5, 0.35, stats_lines,
627
+ transform=ax_stats.transAxes,
628
+ fontsize=10,
629
+ fontfamily="monospace",
630
+ color=TEXT_COLOR,
631
+ verticalalignment="center",
632
+ horizontalalignment="center",
633
+ bbox=props,
634
+ )
635
+
636
+ plt.tight_layout()
637
+
638
+ output_path.parent.mkdir(parents=True, exist_ok=True)
639
+ fig.savefig(str(output_path), dpi=150, facecolor=fig.get_facecolor())
640
+ plt.close(fig)
641
+
642
+ ok(f"Graph image saved to {output_path}")
643
+ print()
644
+
645
+
646
+ def main() -> int:
647
+ parser = argparse.ArgumentParser(description=__doc__)
648
+ parser.add_argument(
649
+ "--repo-root",
650
+ default=Path(__file__).resolve().parents[2],
651
+ type=Path,
652
+ )
653
+ parser.add_argument(
654
+ "--image",
655
+ default=None,
656
+ type=Path,
657
+ help="Output path for graph PNG (e.g., docs/wiki/thread-graph.png)",
658
+ )
659
+ parser.add_argument(
660
+ "--json",
661
+ default=None,
662
+ type=Path,
663
+ help="Output path for health JSON (e.g., docs/wiki/graph-health.json)",
664
+ )
665
+ parser.add_argument(
666
+ "--max-hops",
667
+ default=3,
668
+ type=int,
669
+ help="Maximum hops for reachability check (default: 3)",
670
+ )
671
+ parser.add_argument(
672
+ "--graph",
673
+ default=None,
674
+ type=str,
675
+ help="Filter by graph namespace (e.g., 'design', 'user'). Default: all threads.",
676
+ )
677
+ args = parser.parse_args()
678
+
679
+ repo_root = args.repo_root.resolve()
680
+ memex_dir = repo_root / "gator-command"
681
+
682
+ # Ensure UTF-8 output on Windows
683
+ if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
684
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
685
+
686
+ graph_label = f" ({args.graph} layer)" if args.graph else ""
687
+ print("==============================")
688
+ print(f" Graph Health Report{graph_label}")
689
+ print("==============================")
690
+ print()
691
+
692
+ G, path_to_title = build_graph(memex_dir, graph_filter=args.graph)
693
+
694
+ # Validate --graph filter: fail on empty result from a non-None filter
695
+ if args.graph is not None and G.number_of_nodes() == 0:
696
+ # Check if the graph name exists at all
697
+ all_threads = load_threads(memex_dir)
698
+ known_graphs = sorted(set(t.graph for t in all_threads))
699
+ error(f"No threads found with graph: {args.graph}")
700
+ info(f"Known graph values: {', '.join(known_graphs)}")
701
+ return 1
702
+
703
+ health = compute_health(G, path_to_title, max_hops=args.max_hops)
704
+
705
+ info(f"Nodes: {health['nodes']}, Edges: {health['edges']}, Avg degree: {health['avg_degree']}")
706
+ info(f"Components: {health['components']}, Clusters: {health['clusters']}, Bridges: {health['bridges']}, Orphans: {len(health['orphans'])}")
707
+ print()
708
+
709
+ errors = 0
710
+ report_clusters(health)
711
+ errors += report_backlinks(G, path_to_title)
712
+ errors += report_reachability(G, path_to_title, health, max_hops=args.max_hops)
713
+ errors += report_bridges(G, path_to_title)
714
+ errors += report_efficiency(health)
715
+ errors += report_legibility(health)
716
+
717
+ # Health score summary
718
+ print("-- Health Score (v2 — subway model) --")
719
+ verdict_color = GREEN if health["verdict"] == "HEALTHY" else (YELLOW if health["verdict"] == "FAIR" else RED)
720
+ print(f"{verdict_color}{health['verdict']}{NC} ({health['overall']}/100)")
721
+ info(f"Navigability: {health['navigability_score']} Resilience: {health['resilience_score']} Connectivity: {health['connectivity_score']} Efficiency: {health['efficiency_score']} Legibility: {health['legibility_score']}")
722
+ print()
723
+
724
+ if args.image:
725
+ image_path = args.image
726
+ if not image_path.is_absolute():
727
+ image_path = repo_root / image_path
728
+ render_graph_image(G, path_to_title, image_path, health=health)
729
+
730
+ if args.json:
731
+ json_path = args.json
732
+ if not json_path.is_absolute():
733
+ json_path = repo_root / json_path
734
+ json_path.parent.mkdir(parents=True, exist_ok=True)
735
+ json_data = {
736
+ "date": dt.date.today().isoformat(),
737
+ "model": "v2.1-subway",
738
+ "verdict": health["verdict"],
739
+ "overall": health["overall"],
740
+ "nodes": health["nodes"],
741
+ "edges": health["edges"],
742
+ "avg_degree": health["avg_degree"],
743
+ "components": health["components"],
744
+ "clusters": health["clusters"],
745
+ "cluster_summary": health["cluster_summary"],
746
+ "bridges": health["bridges"],
747
+ "orphans": health["orphans"],
748
+ "intra_cluster_orphans": health["intra_cluster_orphans"],
749
+ "intra_hop_violations": len(health["intra_hop_violations"]),
750
+ "cross_hop_violations": len(health["cross_hop_violations"]),
751
+ "redundancy_ratio": health["redundancy_ratio"],
752
+ "redundant_edges": health["redundant_edges"],
753
+ "total_undirected_edges": health["total_undirected_edges"],
754
+ "peripherals": health["peripherals"],
755
+ "peripherals_stranded": health["peripherals_stranded"],
756
+ "hubs": health["hubs"],
757
+ "scores": {
758
+ "navigability": health["navigability_score"],
759
+ "resilience": health["resilience_score"],
760
+ "connectivity": health["connectivity_score"],
761
+ "efficiency": health["efficiency_score"],
762
+ "legibility": health["legibility_score"],
763
+ },
764
+ }
765
+ json_path.write_text(json.dumps(json_data, indent=2) + "\n", encoding="utf-8")
766
+ ok(f"Health JSON saved to {json_path}")
767
+ print()
768
+
769
+ print("==============================")
770
+ if errors == 0:
771
+ print(f"{GREEN}All graph-health checks passed.{NC}")
772
+ else:
773
+ print(f"{RED}{errors} issue(s) found.{NC}")
774
+ print("==============================")
775
+
776
+ return min(errors, 1)
777
+
778
+
779
+ if __name__ == "__main__":
780
+ raise SystemExit(main())