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.
- gator_command/__init__.py +2 -0
- gator_command/cli.py +137 -0
- gator_command/scripts/crawler.py +633 -0
- gator_command/scripts/dashboard/dashboard.css +982 -0
- gator_command/scripts/dashboard/dashboard.html +84 -0
- gator_command/scripts/dashboard/dashboard.js +419 -0
- gator_command/scripts/dashboard/views/audit.js +270 -0
- gator_command/scripts/dashboard/views/fleet.js +307 -0
- gator_command/scripts/dashboard/views/repo.js +599 -0
- gator_command/scripts/dashboard/views/settings.js +173 -0
- gator_command/scripts/dashboard/views/updates.js +308 -0
- gator_command/scripts/enforcer-prompt.md +22 -0
- gator_command/scripts/extract-claude-sessions.py +489 -0
- gator_command/scripts/extract-codex-sessions.py +477 -0
- gator_command/scripts/extract-gemini-sessions.py +410 -0
- gator_command/scripts/gator-audit.py +956 -0
- gator_command/scripts/gator-charter-draft.py +919 -0
- gator_command/scripts/gator-charter-lint.py +427 -0
- gator_command/scripts/gator-charter-verify.py +606 -0
- gator_command/scripts/gator-dashboard.py +1271 -0
- gator_command/scripts/gator-deploy.py +916 -0
- gator_command/scripts/gator-drift.py +569 -0
- gator_command/scripts/gator-enforce.py +82 -0
- gator_command/scripts/gator-fleet-intel.py +460 -0
- gator_command/scripts/gator-fleet-report.py +615 -0
- gator_command/scripts/gator-init-command-post.py +315 -0
- gator_command/scripts/gator-init.py +434 -0
- gator_command/scripts/gator-machine-id.py +153 -0
- gator_command/scripts/gator-policy-status.py +631 -0
- gator_command/scripts/gator-pulse.py +459 -0
- gator_command/scripts/gator-repo-status.py +649 -0
- gator_command/scripts/gator-session-common.py +372 -0
- gator_command/scripts/gator-session-sink.py +831 -0
- gator_command/scripts/gator-sessions.py +1244 -0
- gator_command/scripts/gator-update.py +615 -0
- gator_command/scripts/gator-version.py +38 -0
- gator_command/scripts/gator_core.py +489 -0
- gator_command/scripts/gator_remote.py +381 -0
- gator_command/scripts/gator_runtime.py +142 -0
- gator_command/scripts/gatorize-actions.sh +989 -0
- gator_command/scripts/gatorize-lib.sh +166 -0
- gator_command/scripts/gatorize-post.sh +394 -0
- gator_command/scripts/gatorize.py +1163 -0
- gator_command/scripts/gatorize.sh +185 -0
- gator_command/scripts/generate_markdown.py +212 -0
- gator_command/scripts/generate_wiki.py +424 -0
- gator_command/scripts/graph_health.py +780 -0
- gator_command/scripts/memex-lint.py +286 -0
- gator_command/scripts/memex-lint.sh +205 -0
- gator_command/scripts/memex.py +1472 -0
- gator_command/scripts/memex_formatters.py +191 -0
- gator_command/scripts/memex_state.py +236 -0
- gator_command/scripts/spawn.py +650 -0
- gator_command/templates/gator-starter/blueprints/README.md +32 -0
- gator_command/templates/gator-starter/charterignore +53 -0
- gator_command/templates/gator-starter/charters/README.md +178 -0
- gator_command/templates/gator-starter/charters/_template.md +31 -0
- gator_command/templates/gator-starter/commands/commit.md +33 -0
- gator_command/templates/gator-starter/commands/init.md +11 -0
- gator_command/templates/gator-starter/commands/update.md +5 -0
- gator_command/templates/gator-starter/constitution.md +165 -0
- gator_command/templates/gator-starter/field-guides/README.md +25 -0
- gator_command/templates/gator-starter/gator-start-up.md +119 -0
- gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
- gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
- gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
- gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
- gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
- gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
- gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
- gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
- gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
- gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
- gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
- gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
- gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
- gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
- gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
- gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
- gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
- gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
- gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
- gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
- gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
- gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
- gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
- gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
- gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
- gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
- gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
- gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
- gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
- gator_command/templates/gator-starter/sessions/.gitignore +7 -0
- gator_command/templates/gator-starter/vault/.gitkeep +0 -0
- gator_command/templates/gator-starter/whiteboard.md +5 -0
- gator_command-1.0.0.dist-info/METADATA +122 -0
- gator_command-1.0.0.dist-info/RECORD +110 -0
- gator_command-1.0.0.dist-info/WHEEL +5 -0
- gator_command-1.0.0.dist-info/entry_points.txt +2 -0
- gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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())
|