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,633 @@
1
+ #!/usr/bin/env python3
2
+ """Crawler — automated Memex maintenance operator.
3
+
4
+ Runs graph_health.py, triages findings against the graph-health-response
5
+ procedure thresholds, and either produces a report (dry-run) or invokes
6
+ Sonnet to propose fixes on a maintenance branch (fix mode).
7
+
8
+ Usage:
9
+ python gator-command/scripts/crawler.py # dry-run: report only
10
+ python gator-command/scripts/crawler.py --fix # propose fixes via Sonnet
11
+ python gator-command/scripts/crawler.py --graph design # check only the design layer
12
+ python gator-command/scripts/crawler.py --fix --no-branch # fix mode without git branching
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as dt
19
+ import io
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ import textwrap
24
+ from pathlib import Path
25
+
26
+ # ── Paths ──────────────────────────────────────────────────────────────
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parents[2]
29
+ MEMEX_DIR = REPO_ROOT / "gator-command"
30
+ SCRIPTS_DIR = Path(__file__).resolve().parent
31
+ REPORTS_DIR = REPO_ROOT / "docs" / "reports"
32
+ PROCEDURE_PATH = REPO_ROOT / "gator-command" / "procedures" / "graph-health-response.md"
33
+
34
+ # ── Colors ─────────────────────────────────────────────────────────────
35
+
36
+ RED = "\033[0;31m"
37
+ YELLOW = "\033[0;33m"
38
+ GREEN = "\033[0;32m"
39
+ CYAN = "\033[0;36m"
40
+ BOLD = "\033[1m"
41
+ NC = "\033[0m"
42
+
43
+
44
+ def run_health_check(graph_filter: str | None = None) -> dict:
45
+ """Run graph_health.py --json and return parsed results."""
46
+ cmd = [
47
+ sys.executable,
48
+ str(SCRIPTS_DIR / "graph_health.py"),
49
+ "--json", str(REPO_ROOT / "wiki" / "graph-health-crawler.json"),
50
+ "--repo-root", str(REPO_ROOT),
51
+ ]
52
+ if graph_filter:
53
+ cmd.extend(["--graph", graph_filter])
54
+
55
+ result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
56
+
57
+ json_path = REPO_ROOT / "wiki" / "graph-health-crawler.json"
58
+
59
+ # graph_health.py exits 1 when findings exist (normal) — only fail if
60
+ # it didn't produce JSON output (actual error, e.g., bad --graph filter)
61
+ if not json_path.exists():
62
+ output = (result.stdout or "") + (result.stderr or "")
63
+ import re
64
+ clean = re.sub(r"\033\[[0-9;]*m", "", output)
65
+ print(f"{RED}ERROR:{NC} graph_health.py failed to produce output")
66
+ for line in clean.strip().splitlines()[-5:]:
67
+ print(f" {line}")
68
+ sys.exit(1)
69
+
70
+ return json.loads(json_path.read_text(encoding="utf-8"))
71
+
72
+
73
+ def triage(health: dict) -> list[dict]:
74
+ """Triage health data against procedure thresholds. Returns findings."""
75
+ findings = []
76
+ scores = health.get("scores", {})
77
+
78
+ # Navigability
79
+ nav = scores.get("navigability", 100)
80
+ intra = health.get("intra_hop_violations", 0)
81
+ cross = health.get("cross_hop_violations", 0)
82
+ if nav < 70:
83
+ findings.append({
84
+ "dimension": "navigability",
85
+ "severity": "red",
86
+ "score": nav,
87
+ "detail": f"{intra} intra-cluster + {cross} cross-cluster violations",
88
+ "action": "Propose transfer station links for intra-cluster violations (worst first)",
89
+ })
90
+ elif nav < 90:
91
+ findings.append({
92
+ "dimension": "navigability",
93
+ "severity": "yellow",
94
+ "score": nav,
95
+ "detail": f"{intra} intra-cluster + {cross} cross-cluster violations",
96
+ "action": "Report intra-cluster violations; suggest candidate transfer station links",
97
+ })
98
+
99
+ # Resilience
100
+ res = scores.get("resilience", 100)
101
+ bridges = health.get("bridges", 0)
102
+ if res < 70:
103
+ findings.append({
104
+ "dimension": "resilience",
105
+ "severity": "red",
106
+ "score": res,
107
+ "detail": f"{bridges} bridge edge(s)",
108
+ "action": "Propose redundant paths for all bridge edges (urgent)",
109
+ })
110
+ elif res < 100:
111
+ findings.append({
112
+ "dimension": "resilience",
113
+ "severity": "yellow",
114
+ "score": res,
115
+ "detail": f"{bridges} bridge edge(s)",
116
+ "action": "Propose one redundant path per bridge edge",
117
+ })
118
+
119
+ # Connectivity
120
+ conn = scores.get("connectivity", 100)
121
+ orphans = health.get("orphans", [])
122
+ intra_orphans = health.get("intra_cluster_orphans", [])
123
+ if conn < 50:
124
+ findings.append({
125
+ "dimension": "connectivity",
126
+ "severity": "red",
127
+ "score": conn,
128
+ "detail": f"{len(intra_orphans)} intra-cluster orphan(s), {len(orphans)} global orphan(s)",
129
+ "action": "Propose inbound links for all intra-cluster orphans",
130
+ "orphans": intra_orphans,
131
+ })
132
+ elif conn < 80:
133
+ findings.append({
134
+ "dimension": "connectivity",
135
+ "severity": "yellow",
136
+ "score": conn,
137
+ "detail": f"{len(intra_orphans)} intra-cluster orphan(s), {len(orphans)} global orphan(s)",
138
+ "action": "Report intra-cluster orphans; suggest cluster neighbor inbound links",
139
+ "orphans": intra_orphans,
140
+ })
141
+
142
+ # Efficiency
143
+ eff = scores.get("efficiency", 100)
144
+ ratio = health.get("redundancy_ratio", 0.5)
145
+ if ratio > 0.75:
146
+ findings.append({
147
+ "dimension": "efficiency",
148
+ "severity": "red",
149
+ "score": eff,
150
+ "detail": f"Redundancy ratio {ratio:.0%} (>75%)",
151
+ "action": "Propose pruning candidates — list redundant edges sorted by easiest to prune",
152
+ })
153
+ elif ratio > 0.60:
154
+ findings.append({
155
+ "dimension": "efficiency",
156
+ "severity": "yellow",
157
+ "score": eff,
158
+ "detail": f"Redundancy ratio {ratio:.0%} (>60%)",
159
+ "action": "Flag over-linked threads; list threads with most redundant connections",
160
+ })
161
+ elif ratio < 0.30:
162
+ findings.append({
163
+ "dimension": "efficiency",
164
+ "severity": "yellow",
165
+ "score": eff,
166
+ "detail": f"Redundancy ratio {ratio:.0%} (<30%)",
167
+ "action": "Flag fragile tree structure; suggest adding redundant paths",
168
+ })
169
+
170
+ # Legibility
171
+ leg = scores.get("legibility", 100)
172
+ stranded = health.get("peripherals_stranded", [])
173
+ if leg < 70:
174
+ findings.append({
175
+ "dimension": "legibility",
176
+ "severity": "red",
177
+ "score": leg,
178
+ "detail": f"{len(stranded)} stranded peripheral(s)",
179
+ "action": "Propose hub connections for all stranded peripherals",
180
+ "stranded": stranded,
181
+ })
182
+ elif leg < 90:
183
+ findings.append({
184
+ "dimension": "legibility",
185
+ "severity": "yellow",
186
+ "score": leg,
187
+ "detail": f"{len(stranded)} stranded peripheral(s)",
188
+ "action": "Report stranded peripherals; suggest hub connections",
189
+ "stranded": stranded,
190
+ })
191
+
192
+ return findings
193
+
194
+
195
+ def print_report(health: dict, findings: list[dict], graph_filter: str | None) -> str:
196
+ """Print triage report to console and return as string for file output."""
197
+ today = dt.date.today().isoformat()
198
+ graph_label = f" ({graph_filter} layer)" if graph_filter else ""
199
+
200
+ lines = []
201
+ lines.append(f"# Crawler Report — {today}{graph_label}")
202
+ lines.append(f"")
203
+ lines.append(f"Model: {health.get('model', 'unknown')}")
204
+ lines.append(f"Verdict: **{health['verdict']}** ({health['overall']}/100)")
205
+ lines.append(f"")
206
+ lines.append(f"| Dimension | Score |")
207
+ lines.append(f"|-----------|-------|")
208
+ for dim in ["navigability", "resilience", "connectivity", "efficiency", "legibility"]:
209
+ score = health.get("scores", {}).get(dim, "?")
210
+ lines.append(f"| {dim.capitalize()} | {score} |")
211
+ lines.append(f"")
212
+ lines.append(f"Nodes: {health['nodes']} Edges: {health['edges']} "
213
+ f"Clusters: {health.get('clusters', '?')} "
214
+ f"Redundancy: {health.get('redundancy_ratio', 0):.0%}")
215
+ lines.append(f"")
216
+
217
+ if not findings:
218
+ lines.append(f"## Findings")
219
+ lines.append(f"")
220
+ lines.append(f"All dimensions green. No action needed.")
221
+ else:
222
+ lines.append(f"## Findings ({len(findings)})")
223
+ lines.append(f"")
224
+ for i, f in enumerate(findings, 1):
225
+ severity_icon = "🔴" if f["severity"] == "red" else "🟡"
226
+ lines.append(f"### {i}. {f['dimension'].capitalize()} — {f['severity'].upper()}")
227
+ lines.append(f"")
228
+ lines.append(f"Score: {f['score']}/100")
229
+ lines.append(f"Detail: {f['detail']}")
230
+ lines.append(f"Action: {f['action']}")
231
+ if "orphans" in f:
232
+ lines.append(f"Orphans: {', '.join(f['orphans'])}")
233
+ if "stranded" in f:
234
+ lines.append(f"Stranded: {', '.join(f['stranded'])}")
235
+ lines.append(f"")
236
+
237
+ report_text = "\n".join(lines)
238
+
239
+ # Console output
240
+ print(f"\n{BOLD}{'=' * 40}{NC}")
241
+ print(f"{BOLD} Crawler Report{graph_label}{NC}")
242
+ print(f"{BOLD}{'=' * 40}{NC}\n")
243
+
244
+ verdict_color = GREEN if health["verdict"] == "HEALTHY" else (YELLOW if health["verdict"] == "FAIR" else RED)
245
+ print(f" {verdict_color}{health['verdict']}{NC} ({health['overall']}/100)\n")
246
+
247
+ scores = health.get("scores", {})
248
+ for dim in ["navigability", "resilience", "connectivity", "efficiency", "legibility"]:
249
+ score = scores.get(dim, 0)
250
+ color = GREEN if score >= 90 else (YELLOW if score >= 70 else RED)
251
+ print(f" {color}{score:3d}{NC} {dim.capitalize()}")
252
+ print()
253
+
254
+ if not findings:
255
+ print(f" {GREEN}All dimensions green. No action needed.{NC}\n")
256
+ else:
257
+ print(f" {YELLOW}{len(findings)} finding(s):{NC}\n")
258
+ for f in findings:
259
+ sev_color = RED if f["severity"] == "red" else YELLOW
260
+ print(f" {sev_color}[{f['severity'].upper()}]{NC} {f['dimension'].capitalize()}: {f['detail']}")
261
+ print(f" → {f['action']}")
262
+ print()
263
+
264
+ return report_text
265
+
266
+
267
+ def build_thread_inventory(memex_dir: Path, graph_filter: str | None = None) -> str:
268
+ """Build an inventory of thread files with their titles and paths for Sonnet."""
269
+ sys.path.insert(0, str(SCRIPTS_DIR))
270
+ from generate_wiki import load_threads
271
+ threads = load_threads(memex_dir)
272
+ if graph_filter:
273
+ threads = [t for t in threads if t.graph == graph_filter]
274
+
275
+ lines = []
276
+ for t in sorted(threads, key=lambda t: t.title):
277
+ rel_path = t.source_path.relative_to(REPO_ROOT)
278
+ lines.append(f" {rel_path} | {t.title} | graph:{t.graph}")
279
+ return "\n".join(lines)
280
+
281
+
282
+ def build_sonnet_prompt(health: dict, findings: list[dict], procedure_text: str,
283
+ thread_inventory: str) -> str:
284
+ """Build the prompt for Sonnet to propose fixes."""
285
+ health_summary = json.dumps(health, indent=2)
286
+
287
+ findings_text = ""
288
+ for f in findings:
289
+ findings_text += f"\n- [{f['severity'].upper()}] {f['dimension']}: {f['detail']}\n"
290
+ findings_text += f" Action: {f['action']}\n"
291
+ if "orphans" in f:
292
+ findings_text += f" Orphans: {', '.join(f['orphans'])}\n"
293
+ if "stranded" in f:
294
+ findings_text += f" Stranded: {', '.join(f['stranded'])}\n"
295
+
296
+ return textwrap.dedent(f"""\
297
+ You are the Crawler — an automated maintenance operator for a Memex knowledge graph.
298
+
299
+ Your job: propose specific, minimal fixes for the findings below. Each fix is an edit
300
+ to a thread file's ## Connections section (add or remove a link). Every connection you
301
+ add must have a genuine semantic justification in the annotation.
302
+
303
+ ## Procedure (your governing rules)
304
+
305
+ {procedure_text}
306
+
307
+ ## Thread Inventory (exact file paths — use these, do not guess paths)
308
+
309
+ {thread_inventory}
310
+
311
+ ## Current Health Data
312
+
313
+ ```json
314
+ {health_summary}
315
+ ```
316
+
317
+ ## Findings Requiring Fixes
318
+
319
+ {findings_text}
320
+
321
+ ## Output Format (STRICT — must be machine-parseable)
322
+
323
+ Output ONLY fix blocks in this exact format, one per fix. No prose before or after.
324
+ No markdown headers. No explanations outside the blocks. Start immediately with FIX 1.
325
+
326
+ FIX 1
327
+ FILE: gator-command/threads/example-thread.md
328
+ ACTION: add_connection
329
+ LINE: → [Target Thread](relative-path-to-target.md) — semantic justification
330
+ REASON: One sentence explaining why this connection is genuine.
331
+
332
+ FIX 2
333
+ FILE: gator-command/threads/another-thread.md
334
+ ACTION: remove_connection
335
+ LINE: → [Target Thread](relative-path-to-target.md) — the existing line to remove
336
+ REASON: One sentence explaining why this removal is safe.
337
+
338
+ Rules:
339
+ - Use EXACT file paths from the Thread Inventory above
340
+ - For link targets in the LINE, use paths relative to the source file's directory
341
+ - Every added connection must be semantically genuine — the REASON must explain WHY
342
+ - No thread should have fewer than 2 outbound connections after changes
343
+ - Do not add connections just to improve a score — the multi-interest principle applies
344
+ - Prefer adding one well-chosen link over multiple weak ones
345
+ - For orphans: add one inbound link from the most semantically related cluster neighbor
346
+ - For bridges: add one redundant path via a third thread related to both endpoints
347
+ """)
348
+
349
+
350
+ def call_sonnet(prompt: str) -> str:
351
+ """Call Sonnet via Anthropic API. Returns the response text."""
352
+ try:
353
+ import anthropic
354
+ except ImportError:
355
+ print(f"{RED}ERROR:{NC} anthropic package not installed. Run: pip install anthropic")
356
+ sys.exit(1)
357
+
358
+ import os
359
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
360
+
361
+ # Fallback: load from VS Code settings if not in environment
362
+ if not api_key:
363
+ vscode_settings = Path.home() / "AppData" / "Roaming" / "Code" / "User" / "settings.json"
364
+ if vscode_settings.exists():
365
+ try:
366
+ import json as _json
367
+ vs = _json.loads(vscode_settings.read_text(encoding="utf-8"))
368
+ api_key = vs.get("terminal.integrated.env.windows", {}).get("ANTHROPIC_API_KEY")
369
+ except Exception:
370
+ pass
371
+
372
+ if not api_key:
373
+ print(f"{RED}ERROR:{NC} ANTHROPIC_API_KEY not found in environment or VS Code settings")
374
+ sys.exit(1)
375
+
376
+ client = anthropic.Anthropic(api_key=api_key)
377
+ response = client.messages.create(
378
+ model="claude-sonnet-4-20250514",
379
+ max_tokens=4096,
380
+ messages=[{"role": "user", "content": prompt}],
381
+ )
382
+ return response.content[0].text
383
+
384
+
385
+ def parse_fixes(response: str) -> list[dict]:
386
+ """Parse Sonnet's FIX blocks into structured edits."""
387
+ fixes = []
388
+ current: dict | None = None
389
+
390
+ for line in response.splitlines():
391
+ stripped = line.strip()
392
+ if stripped.startswith("FIX "):
393
+ if current and "file" in current:
394
+ fixes.append(current)
395
+ current = {}
396
+ elif current is not None:
397
+ if stripped.startswith("FILE:"):
398
+ current["file"] = stripped[5:].strip()
399
+ elif stripped.startswith("ACTION:"):
400
+ current["action"] = stripped[7:].strip()
401
+ elif stripped.startswith("LINE:"):
402
+ current["line"] = stripped[5:].strip()
403
+ elif stripped.startswith("REASON:"):
404
+ current["reason"] = stripped[7:].strip()
405
+
406
+ if current and "file" in current:
407
+ fixes.append(current)
408
+
409
+ return fixes
410
+
411
+
412
+ def apply_fixes(fixes: list[dict]) -> list[str]:
413
+ """Apply parsed fixes to thread files. Returns list of applied descriptions."""
414
+ applied = []
415
+
416
+ for fix in fixes:
417
+ file_path = REPO_ROOT / fix["file"]
418
+ action = fix.get("action", "")
419
+ line = fix.get("line", "")
420
+ reason = fix.get("reason", "")
421
+
422
+ if not file_path.exists():
423
+ print(f" {YELLOW}SKIP:{NC} File not found: {fix['file']}")
424
+ continue
425
+
426
+ if not line:
427
+ print(f" {YELLOW}SKIP:{NC} No LINE specified for {fix['file']}")
428
+ continue
429
+
430
+ text = file_path.read_text(encoding="utf-8")
431
+
432
+ if action == "add_connection":
433
+ # Find the ## Connections section and append the line
434
+ if "## Connections" not in text:
435
+ print(f" {YELLOW}SKIP:{NC} No ## Connections section in {fix['file']}")
436
+ continue
437
+
438
+ # Insert before the next ## heading or at end of file
439
+ sections = text.split("## Connections")
440
+ if len(sections) < 2:
441
+ continue
442
+
443
+ before = sections[0] + "## Connections"
444
+ after = sections[1]
445
+
446
+ # Find where the connections section ends (next ## or end of file)
447
+ after_lines = after.split("\n")
448
+ insert_idx = len(after_lines)
449
+ for i, aline in enumerate(after_lines):
450
+ if i > 0 and aline.startswith("## "):
451
+ insert_idx = i
452
+ break
453
+
454
+ # Insert the new connection line before the next section
455
+ # Find the last non-empty line in the connections section
456
+ last_content = insert_idx - 1
457
+ while last_content > 0 and not after_lines[last_content].strip():
458
+ last_content -= 1
459
+
460
+ after_lines.insert(last_content + 1, line)
461
+ new_text = before + "\n".join(after_lines)
462
+
463
+ file_path.write_text(new_text, encoding="utf-8")
464
+ desc = f"ADD {fix['file']}: {line[:80]}"
465
+ print(f" {GREEN}APPLIED:{NC} {desc}")
466
+ applied.append(desc)
467
+
468
+ elif action == "remove_connection":
469
+ if line in text:
470
+ new_text = text.replace(line + "\n", "")
471
+ if new_text == text:
472
+ new_text = text.replace(line, "")
473
+ file_path.write_text(new_text, encoding="utf-8")
474
+ desc = f"REMOVE {fix['file']}: {line[:80]}"
475
+ print(f" {GREEN}APPLIED:{NC} {desc}")
476
+ applied.append(desc)
477
+ else:
478
+ print(f" {YELLOW}SKIP:{NC} Line not found in {fix['file']}")
479
+
480
+ else:
481
+ print(f" {YELLOW}SKIP:{NC} Unknown action '{action}' for {fix['file']}")
482
+
483
+ return applied
484
+
485
+
486
+ def create_maintenance_branch() -> str:
487
+ """Create and checkout a maintenance branch. Returns branch name."""
488
+ today = dt.date.today().isoformat()
489
+ branch_name = f"crawler/maintenance-{today}"
490
+
491
+ # Check if branch already exists
492
+ result = subprocess.run(
493
+ ["git", "branch", "--list", branch_name],
494
+ capture_output=True, text=True, cwd=str(REPO_ROOT),
495
+ )
496
+ if result.stdout.strip():
497
+ # Branch exists, just check it out
498
+ subprocess.run(["git", "checkout", branch_name], cwd=str(REPO_ROOT), check=True)
499
+ else:
500
+ subprocess.run(["git", "checkout", "-b", branch_name], cwd=str(REPO_ROOT), check=True)
501
+
502
+ return branch_name
503
+
504
+
505
+ def main() -> int:
506
+ parser = argparse.ArgumentParser(description=__doc__,
507
+ formatter_class=argparse.RawDescriptionHelpFormatter)
508
+ parser.add_argument(
509
+ "--fix", action="store_true",
510
+ help="Invoke Sonnet to propose fixes (requires ANTHROPIC_API_KEY)",
511
+ )
512
+ parser.add_argument(
513
+ "--graph", default=None, type=str,
514
+ help="Filter by graph namespace (e.g., 'design', 'user')",
515
+ )
516
+ parser.add_argument(
517
+ "--no-branch", action="store_true",
518
+ help="In fix mode, don't create a git branch (just output fixes)",
519
+ )
520
+ args = parser.parse_args()
521
+
522
+ # Ensure UTF-8 output on Windows
523
+ if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
524
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
525
+
526
+ # Step 1: Run health check
527
+ print(f"{CYAN}Running graph health check...{NC}")
528
+ health = run_health_check(graph_filter=args.graph)
529
+
530
+ # Step 2: Triage
531
+ findings = triage(health)
532
+
533
+ # Step 3: Report
534
+ report_text = print_report(health, findings, args.graph)
535
+
536
+ # Save report
537
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
538
+ today = dt.date.today().isoformat()
539
+ graph_suffix = f"-{args.graph}" if args.graph else ""
540
+ report_path = REPORTS_DIR / f"{today}-crawler-report{graph_suffix}.md"
541
+ report_path.write_text(report_text, encoding="utf-8")
542
+ print(f" Report saved to {report_path}\n")
543
+
544
+ # Step 4: If dry-run, stop here
545
+ if not args.fix:
546
+ if findings:
547
+ print(f" {YELLOW}Dry run. Use --fix to invoke Sonnet for proposed fixes.{NC}")
548
+ return 1 if any(f["severity"] == "red" for f in findings) else 0
549
+
550
+ # Step 5: Fix mode — call Sonnet
551
+ if not findings:
552
+ print(f" {GREEN}No findings to fix.{NC}")
553
+ return 0
554
+
555
+ # Read the procedure
556
+ if not PROCEDURE_PATH.exists():
557
+ print(f"{RED}ERROR:{NC} Procedure not found: {PROCEDURE_PATH}")
558
+ return 1
559
+ procedure_text = PROCEDURE_PATH.read_text(encoding="utf-8")
560
+
561
+ # Create maintenance branch BEFORE applying fixes
562
+ branch = None
563
+ if not args.no_branch:
564
+ print(f"\n{CYAN}Creating maintenance branch...{NC}")
565
+ branch = create_maintenance_branch()
566
+ print(f" Branch: {branch}")
567
+
568
+ # Build prompt with thread inventory and call Sonnet
569
+ print(f"\n{CYAN}Building thread inventory...{NC}")
570
+ thread_inventory = build_thread_inventory(MEMEX_DIR, graph_filter=args.graph)
571
+ print(f"{CYAN}Invoking Sonnet for fix proposals...{NC}")
572
+ prompt = build_sonnet_prompt(health, findings, procedure_text, thread_inventory)
573
+ response = call_sonnet(prompt)
574
+
575
+ print(f"\n{BOLD}{'=' * 40}{NC}")
576
+ print(f"{BOLD} Sonnet's Response{NC}")
577
+ print(f"{BOLD}{'=' * 40}{NC}\n")
578
+ print(response)
579
+
580
+ # Parse and apply fixes
581
+ fixes = parse_fixes(response)
582
+ if not fixes:
583
+ print(f"\n {YELLOW}No parseable fixes found in Sonnet's response.{NC}")
584
+ full_report = report_text + f"\n\n## Sonnet's Response\n\n{response}\n"
585
+ report_path.write_text(full_report, encoding="utf-8")
586
+ return 0
587
+
588
+ print(f"\n{BOLD}{'=' * 40}{NC}")
589
+ print(f"{BOLD} Applying {len(fixes)} Fix(es){NC}")
590
+ print(f"{BOLD}{'=' * 40}{NC}\n")
591
+
592
+ applied = apply_fixes(fixes)
593
+
594
+ if applied:
595
+ # Re-run health check to verify improvement
596
+ print(f"\n{CYAN}Re-running health check to verify...{NC}")
597
+ new_health = run_health_check(graph_filter=args.graph)
598
+ new_scores = new_health.get("scores", {})
599
+ old_scores = health.get("scores", {})
600
+
601
+ print(f"\n{BOLD} Before → After{NC}\n")
602
+ for dim in ["navigability", "resilience", "connectivity", "efficiency", "legibility"]:
603
+ old = old_scores.get(dim, 0)
604
+ new = new_scores.get(dim, 0)
605
+ delta = new - old
606
+ arrow = f"{GREEN}+{delta}{NC}" if delta > 0 else (f"{RED}{delta}{NC}" if delta < 0 else " 0")
607
+ print(f" {dim.capitalize():15s} {old:3d} → {new:3d} ({arrow})")
608
+ print(f" {'Overall':15s} {health['overall']:3d} → {new_health['overall']:3d}")
609
+
610
+ # Save full report
611
+ applied_text = "\n".join(f"- {a}" for a in applied)
612
+ full_report = (report_text +
613
+ f"\n\n## Sonnet's Response\n\n{response}\n"
614
+ f"\n## Applied Fixes\n\n{applied_text}\n"
615
+ f"\n## Post-Fix Health\n\n"
616
+ f"Verdict: **{new_health['verdict']}** ({new_health['overall']}/100)\n")
617
+ report_path.write_text(full_report, encoding="utf-8")
618
+ print(f"\n Report saved to {report_path}")
619
+
620
+ if branch:
621
+ print(f"\n {CYAN}Fixes applied on branch: {branch}{NC}")
622
+ print(f" Review the changes, then commit and open a PR to dev.")
623
+ print(f" To return to dev: git checkout dev")
624
+ else:
625
+ print(f"\n {YELLOW}No fixes were applied.{NC}")
626
+ full_report = report_text + f"\n\n## Sonnet's Response\n\n{response}\n"
627
+ report_path.write_text(full_report, encoding="utf-8")
628
+
629
+ return 0
630
+
631
+
632
+ if __name__ == "__main__":
633
+ raise SystemExit(main())