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,1472 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Memex CLI — git-style interface to the Memex knowledge graph.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python gator-command/scripts/memex.py status Quick health + inbox count + hot threads
|
|
6
|
+
python gator-command/scripts/memex.py status --full Full session-opening dump
|
|
7
|
+
python gator-command/scripts/memex.py status --full --role agent --format json
|
|
8
|
+
python gator-command/scripts/memex.py search "query" Full-text search across the graph
|
|
9
|
+
python gator-command/scripts/memex.py read thread <name> Render a thread to terminal
|
|
10
|
+
python gator-command/scripts/memex.py hit <thread> Increment hit count + update last-touched
|
|
11
|
+
python gator-command/scripts/memex.py inbox add "text" Add an item to the inbox
|
|
12
|
+
python gator-command/scripts/memex.py connect <from> <to> --why "annotation"
|
|
13
|
+
python gator-command/scripts/memex.py spawn <name> --threads "thread1,thread2"
|
|
14
|
+
python gator-command/scripts/memex.py init
|
|
15
|
+
python gator-command/scripts/memex.py health --image docs/wiki/thread-graph.png
|
|
16
|
+
python gator-command/scripts/memex.py crawl [--fix]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
# --- Reuse existing parsing infrastructure ---
|
|
32
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
33
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
34
|
+
from generate_wiki import (
|
|
35
|
+
extract_links,
|
|
36
|
+
load_threads,
|
|
37
|
+
parse_frontmatter,
|
|
38
|
+
parse_sections,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from memex_formatters import (
|
|
42
|
+
JsonFormatter, get_formatter, RED, YELLOW, GREEN, CYAN, BOLD, DIM, NC,
|
|
43
|
+
)
|
|
44
|
+
import memex_state
|
|
45
|
+
|
|
46
|
+
TODAY = memex_state.TODAY
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_role_context(args: argparse.Namespace):
|
|
50
|
+
"""Load repo, knowledge-layer path, role, and formatter for a command."""
|
|
51
|
+
repo_root = memex_state.find_repo_root(getattr(args, "repo", None))
|
|
52
|
+
memex_dir = repo_root / "gator-command"
|
|
53
|
+
roles = memex_state.load_roles(repo_root)
|
|
54
|
+
role_name = args.role or "architect"
|
|
55
|
+
role = memex_state.get_role(roles, role_name)
|
|
56
|
+
formatter = get_formatter(args.format)
|
|
57
|
+
return repo_root, memex_dir, role_name, role, formatter
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Role configuration ───────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
# ── File readers ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
# ── Output formatting ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Commands ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
71
|
+
"""Show Memex status — quick summary or full session-opening dump."""
|
|
72
|
+
repo_root, memex_dir, _, role, formatter = load_role_context(args)
|
|
73
|
+
|
|
74
|
+
# Collect data
|
|
75
|
+
data: dict = {
|
|
76
|
+
"role_name": args.role or "pi",
|
|
77
|
+
"role": role,
|
|
78
|
+
"show_inbox_items": args.full,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Inbox
|
|
82
|
+
data["inbox"] = memex_state.parse_inbox(memex_dir)
|
|
83
|
+
|
|
84
|
+
# Patterns due
|
|
85
|
+
data["patterns_due"] = memex_state.parse_patterns(memex_dir)
|
|
86
|
+
|
|
87
|
+
# Audit tracker
|
|
88
|
+
data["audit_findings"] = memex_state.parse_audit_tracker(memex_dir)
|
|
89
|
+
|
|
90
|
+
# Active threads
|
|
91
|
+
threads = load_threads(memex_dir)
|
|
92
|
+
active = [t for t in threads if t.tier_label == "Active thread"]
|
|
93
|
+
active.sort(key=lambda t: (
|
|
94
|
+
-t.hits,
|
|
95
|
+
memex_state.thread_staleness(t),
|
|
96
|
+
t.title.lower(),
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
thread_data = []
|
|
100
|
+
for t in active:
|
|
101
|
+
entry = {
|
|
102
|
+
"title": t.title,
|
|
103
|
+
"hits": t.hits,
|
|
104
|
+
"last_touched": t.last_touched,
|
|
105
|
+
"staleness_days": memex_state.thread_staleness(t),
|
|
106
|
+
"tags": t.tags,
|
|
107
|
+
"category": t.category,
|
|
108
|
+
"graph": t.graph,
|
|
109
|
+
}
|
|
110
|
+
if args.full:
|
|
111
|
+
entry["next_up"] = memex_state.get_thread_next_up(t.source_path)
|
|
112
|
+
entry["summary"] = "\n".join(t.summary_lines).strip()
|
|
113
|
+
thread_data.append(entry)
|
|
114
|
+
|
|
115
|
+
data["active_threads"] = thread_data
|
|
116
|
+
|
|
117
|
+
# Graph health (quick score only — not the full expensive report)
|
|
118
|
+
try:
|
|
119
|
+
from graph_health import build_graph, compute_health
|
|
120
|
+
G, path_to_title = build_graph(memex_dir)
|
|
121
|
+
health = compute_health(G, path_to_title)
|
|
122
|
+
data["graph_health"] = {
|
|
123
|
+
"verdict": health["verdict"],
|
|
124
|
+
"overall": health["overall"],
|
|
125
|
+
"nodes": health["nodes"],
|
|
126
|
+
"edges": health["edges"],
|
|
127
|
+
}
|
|
128
|
+
except Exception:
|
|
129
|
+
data["graph_health"] = None
|
|
130
|
+
|
|
131
|
+
print(formatter.status(data))
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
136
|
+
"""Full-text search across the Memex graph."""
|
|
137
|
+
repo_root = memex_state.find_repo_root(getattr(args, 'repo', None))
|
|
138
|
+
memex_dir = repo_root / "gator-command"
|
|
139
|
+
formatter = get_formatter(args.format)
|
|
140
|
+
|
|
141
|
+
query = args.query.lower()
|
|
142
|
+
results = []
|
|
143
|
+
|
|
144
|
+
docs_dir = repo_root / "docs"
|
|
145
|
+
|
|
146
|
+
# Search directories (gator-command/ graph + docs/ project docs)
|
|
147
|
+
search_dirs = [
|
|
148
|
+
(memex_dir / "active-threads", "active-thread"),
|
|
149
|
+
(memex_dir / "threads", "reference-thread"),
|
|
150
|
+
(memex_dir / "artifacts", "artifact"),
|
|
151
|
+
(memex_dir / "reference-notes", "reference-note"),
|
|
152
|
+
(memex_dir / "procedures", "procedure"),
|
|
153
|
+
(repo_root / "blueprints", "blueprint"),
|
|
154
|
+
(docs_dir / "reports", "report"),
|
|
155
|
+
]
|
|
156
|
+
# Also search top-level memex files
|
|
157
|
+
top_level_files = [
|
|
158
|
+
memex_dir / "mission.md",
|
|
159
|
+
memex_dir / "roadmap.md",
|
|
160
|
+
memex_dir / "issues.md",
|
|
161
|
+
memex_dir / "identity.md",
|
|
162
|
+
memex_dir / "inbox.md",
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
for path in top_level_files:
|
|
166
|
+
if not path.exists():
|
|
167
|
+
continue
|
|
168
|
+
matches = _search_file(path, query)
|
|
169
|
+
if matches:
|
|
170
|
+
results.append({
|
|
171
|
+
"file": path.name,
|
|
172
|
+
"type": "top-level",
|
|
173
|
+
"path": str(path),
|
|
174
|
+
"matches": matches,
|
|
175
|
+
"match_count": len(matches),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
for dirpath, type_label in search_dirs:
|
|
179
|
+
if not dirpath.is_dir():
|
|
180
|
+
continue
|
|
181
|
+
for path in sorted(dirpath.glob("*.md")):
|
|
182
|
+
if path.name == "_TEMPLATE.md":
|
|
183
|
+
continue
|
|
184
|
+
matches = _search_file(path, query)
|
|
185
|
+
if matches:
|
|
186
|
+
results.append({
|
|
187
|
+
"file": f"{dirpath.relative_to(repo_root)}/{path.name}",
|
|
188
|
+
"type": type_label,
|
|
189
|
+
"path": str(path),
|
|
190
|
+
"matches": matches,
|
|
191
|
+
"match_count": len(matches),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
# Sort by match count descending (rough relevance)
|
|
195
|
+
results.sort(key=lambda r: -r["match_count"])
|
|
196
|
+
|
|
197
|
+
print(formatter.search_results(results))
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _search_file(path: Path, query: str) -> list[dict]:
|
|
202
|
+
"""Search a single file for a query string. Returns matching lines."""
|
|
203
|
+
try:
|
|
204
|
+
text = path.read_text(encoding="utf-8")
|
|
205
|
+
except (UnicodeDecodeError, OSError):
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
matches = []
|
|
209
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
210
|
+
if query in line.lower():
|
|
211
|
+
matches.append({"line_num": i, "text": line})
|
|
212
|
+
|
|
213
|
+
return matches
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_read(args: argparse.Namespace) -> int:
|
|
217
|
+
"""Read and render a thread or artifact."""
|
|
218
|
+
repo_root = memex_state.find_repo_root(getattr(args, 'repo', None))
|
|
219
|
+
memex_dir = repo_root / "gator-command"
|
|
220
|
+
formatter = get_formatter(args.format)
|
|
221
|
+
|
|
222
|
+
file_type = args.type
|
|
223
|
+
name = args.name
|
|
224
|
+
|
|
225
|
+
# Backward compat: "system" → "blueprint"
|
|
226
|
+
if file_type == "system":
|
|
227
|
+
file_type = "blueprint"
|
|
228
|
+
|
|
229
|
+
docs_dir = repo_root / "docs"
|
|
230
|
+
|
|
231
|
+
# Resolve the file path
|
|
232
|
+
search_dirs = {
|
|
233
|
+
"thread": [memex_dir / "active-threads", memex_dir / "threads"],
|
|
234
|
+
"artifact": [memex_dir / "artifacts"],
|
|
235
|
+
"reference-note": [memex_dir / "reference-notes"],
|
|
236
|
+
"procedure": [memex_dir / "procedures"],
|
|
237
|
+
"blueprint": [repo_root / "blueprints"],
|
|
238
|
+
"report": [docs_dir / "reports"],
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if file_type not in search_dirs:
|
|
242
|
+
print(f"Unknown type: {file_type}. Use: {', '.join(search_dirs.keys())}")
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
# Find the file — try exact match, then fuzzy
|
|
246
|
+
target = None
|
|
247
|
+
for dirpath in search_dirs[file_type]:
|
|
248
|
+
exact = dirpath / f"{name}.md"
|
|
249
|
+
if exact.exists():
|
|
250
|
+
target = exact
|
|
251
|
+
break
|
|
252
|
+
# Fuzzy: find files containing the name
|
|
253
|
+
for path in dirpath.glob("*.md"):
|
|
254
|
+
if path.name == "_TEMPLATE.md":
|
|
255
|
+
continue
|
|
256
|
+
if name.lower() in path.stem.lower():
|
|
257
|
+
target = path
|
|
258
|
+
break
|
|
259
|
+
if target:
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
if not target:
|
|
263
|
+
print(f"Not found: {file_type} '{name}'")
|
|
264
|
+
# Suggest alternatives
|
|
265
|
+
candidates = []
|
|
266
|
+
for dirpath in search_dirs[file_type]:
|
|
267
|
+
if dirpath.is_dir():
|
|
268
|
+
candidates.extend(p.stem for p in dirpath.glob("*.md") if p.name != "_TEMPLATE.md")
|
|
269
|
+
if candidates:
|
|
270
|
+
print(f"Available: {', '.join(sorted(candidates))}")
|
|
271
|
+
return 1
|
|
272
|
+
|
|
273
|
+
# Parse and render
|
|
274
|
+
text = target.read_text(encoding="utf-8")
|
|
275
|
+
fm_text, body = text.split("---\n", 2)[1:] if text.startswith("---") else ("", text)
|
|
276
|
+
frontmatter, _ = parse_frontmatter(text)
|
|
277
|
+
h1, sections = parse_sections(body)
|
|
278
|
+
|
|
279
|
+
data = {
|
|
280
|
+
"title": frontmatter.get("title") or h1 or target.stem,
|
|
281
|
+
"tier": file_type,
|
|
282
|
+
"hits": int(frontmatter.get("hits", "0")),
|
|
283
|
+
"last_touched": frontmatter.get("last-touched", frontmatter.get("last-updated", "unknown")),
|
|
284
|
+
"tags": [t.strip() for t in frontmatter.get("tags", "").strip("[]").split(",") if t.strip()],
|
|
285
|
+
"graph": frontmatter.get("graph", "user"),
|
|
286
|
+
"category": frontmatter.get("category", ""),
|
|
287
|
+
"status": frontmatter.get("status", ""),
|
|
288
|
+
"owner": frontmatter.get("owner", ""),
|
|
289
|
+
"summary": [line for line in sections.get("Summary", sections.get("Overview", [])) if line.strip()],
|
|
290
|
+
"connections": sections.get("Connections", []),
|
|
291
|
+
"next_up": [line.strip()[2:] for line in sections.get("Next Up", []) if line.strip().startswith("- ")],
|
|
292
|
+
"open_questions": [line.strip()[2:] for line in sections.get("Open Questions", []) if line.strip().startswith("- ")],
|
|
293
|
+
"decision_log": sections.get("Decision Log", []),
|
|
294
|
+
"sections": list(sections.keys()),
|
|
295
|
+
"file": str(target.relative_to(repo_root)),
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# Section-level navigation
|
|
299
|
+
if args.section:
|
|
300
|
+
section_name = args.section
|
|
301
|
+
# Fuzzy match section name
|
|
302
|
+
matched = None
|
|
303
|
+
for s in sections:
|
|
304
|
+
if section_name.lower() in s.lower():
|
|
305
|
+
matched = s
|
|
306
|
+
break
|
|
307
|
+
if matched is None:
|
|
308
|
+
print(f"Section '{section_name}' not found in {data['title']}")
|
|
309
|
+
print(f"Available sections: {', '.join(sections.keys())}")
|
|
310
|
+
return 1
|
|
311
|
+
data["_section_name"] = matched
|
|
312
|
+
data["_section_content"] = sections[matched]
|
|
313
|
+
|
|
314
|
+
print(formatter.thread_view(data))
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ── Write-side shared infrastructure ─────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def require_permission(role: dict, role_name: str, permission: str = "write") -> None:
|
|
322
|
+
"""Check that the role has the required permission. Exit if not."""
|
|
323
|
+
perms = role.get("permissions", [])
|
|
324
|
+
if isinstance(perms, str):
|
|
325
|
+
perms = [perms]
|
|
326
|
+
if "all" in perms or permission in perms:
|
|
327
|
+
return
|
|
328
|
+
# write-maintenance permits hit and connect (structural maintenance)
|
|
329
|
+
if permission == "write" and "write-maintenance" in perms:
|
|
330
|
+
return
|
|
331
|
+
print(f"Permission denied: role '{role_name}' does not have '{permission}' permission.")
|
|
332
|
+
raise SystemExit(1)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def rewrite_frontmatter_field(path: Path, updates: dict[str, str | int]) -> dict:
|
|
336
|
+
"""Rewrite specific frontmatter fields in a Markdown file.
|
|
337
|
+
|
|
338
|
+
Operates on raw text line-by-line to preserve formatting.
|
|
339
|
+
Returns dict with old and new values for each updated field.
|
|
340
|
+
"""
|
|
341
|
+
text = path.read_text(encoding="utf-8")
|
|
342
|
+
if not text.startswith("---"):
|
|
343
|
+
raise ValueError(f"File {path.name} has no frontmatter block.")
|
|
344
|
+
|
|
345
|
+
# Split into frontmatter and body
|
|
346
|
+
parts = text.split("---\n", 2)
|
|
347
|
+
if len(parts) < 3:
|
|
348
|
+
raise ValueError(f"File {path.name} has malformed frontmatter.")
|
|
349
|
+
|
|
350
|
+
fm_lines = parts[1].splitlines()
|
|
351
|
+
changes = {}
|
|
352
|
+
|
|
353
|
+
for key, new_value in updates.items():
|
|
354
|
+
found = False
|
|
355
|
+
for i, line in enumerate(fm_lines):
|
|
356
|
+
if line.startswith(f"{key}:"):
|
|
357
|
+
old_value = line.split(":", 1)[1].strip()
|
|
358
|
+
fm_lines[i] = f"{key}: {new_value}"
|
|
359
|
+
changes[key] = {"old": old_value, "new": str(new_value)}
|
|
360
|
+
found = True
|
|
361
|
+
break
|
|
362
|
+
if not found:
|
|
363
|
+
raise ValueError(f"Field '{key}' not found in {path.name} frontmatter.")
|
|
364
|
+
|
|
365
|
+
# Reassemble and write
|
|
366
|
+
new_text = "---\n" + "\n".join(fm_lines) + "\n---\n" + parts[2]
|
|
367
|
+
path.write_text(new_text, encoding="utf-8")
|
|
368
|
+
return changes
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ── Write commands ───────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def cmd_hit(args: argparse.Namespace) -> int:
|
|
375
|
+
"""Increment hit count and update last-touched on a thread."""
|
|
376
|
+
repo_root, memex_dir, role_name, role, formatter = load_role_context(args)
|
|
377
|
+
|
|
378
|
+
require_permission(role, role_name)
|
|
379
|
+
|
|
380
|
+
target = memex_state.resolve_thread(memex_dir, args.name)
|
|
381
|
+
if not target:
|
|
382
|
+
print(f"Thread not found: '{args.name}'")
|
|
383
|
+
candidates = memex_state.list_thread_names(memex_dir)
|
|
384
|
+
if candidates:
|
|
385
|
+
print(f"Available: {', '.join(candidates)}")
|
|
386
|
+
return 1
|
|
387
|
+
|
|
388
|
+
# Read current hits
|
|
389
|
+
text = target.read_text(encoding="utf-8")
|
|
390
|
+
fm, _ = parse_frontmatter(text)
|
|
391
|
+
old_hits = int(fm.get("hits", "0"))
|
|
392
|
+
new_hits = old_hits + 1
|
|
393
|
+
|
|
394
|
+
changes = rewrite_frontmatter_field(target, {
|
|
395
|
+
"hits": new_hits,
|
|
396
|
+
"last-touched": TODAY.isoformat(),
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
provenance = role.get("provenance", f"-{role_name}")
|
|
400
|
+
result = {
|
|
401
|
+
"command": "hit",
|
|
402
|
+
"thread": target.stem,
|
|
403
|
+
"file": str(target.relative_to(repo_root)),
|
|
404
|
+
"hits": {"old": old_hits, "new": new_hits},
|
|
405
|
+
"last_touched": changes.get("last-touched", {}),
|
|
406
|
+
"provenance": provenance,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if isinstance(formatter, JsonFormatter):
|
|
410
|
+
print(json.dumps(result, indent=2))
|
|
411
|
+
else:
|
|
412
|
+
old_lt = changes.get("last-touched", {}).get("old", "?")
|
|
413
|
+
print(f" {GREEN}✓{NC} {BOLD}{target.stem}{NC} "
|
|
414
|
+
f"hits: {old_hits} → {new_hits} "
|
|
415
|
+
f"last-touched: {TODAY.isoformat()}")
|
|
416
|
+
|
|
417
|
+
return 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def cmd_inbox(args: argparse.Namespace) -> int:
|
|
421
|
+
"""Inbox operations (add)."""
|
|
422
|
+
if args.inbox_command == "add":
|
|
423
|
+
return cmd_inbox_add(args)
|
|
424
|
+
# Future: list, clear, etc.
|
|
425
|
+
print("Usage: memex inbox add \"text\"")
|
|
426
|
+
return 1
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def cmd_inbox_add(args: argparse.Namespace) -> int:
|
|
430
|
+
"""Add an item to the inbox."""
|
|
431
|
+
repo_root, memex_dir, role_name, role, formatter = load_role_context(args)
|
|
432
|
+
|
|
433
|
+
require_permission(role, role_name)
|
|
434
|
+
|
|
435
|
+
text = args.text.strip()
|
|
436
|
+
if not text:
|
|
437
|
+
print("Cannot add empty inbox item.")
|
|
438
|
+
return 1
|
|
439
|
+
|
|
440
|
+
inbox_path = memex_dir / "inbox.md"
|
|
441
|
+
provenance = role.get("provenance", f"-{role_name}")
|
|
442
|
+
|
|
443
|
+
# Count existing items
|
|
444
|
+
old_items = memex_state.parse_inbox(memex_dir)
|
|
445
|
+
old_count = len(old_items)
|
|
446
|
+
|
|
447
|
+
# Append to inbox
|
|
448
|
+
entry = f"- {text} {provenance}\n"
|
|
449
|
+
if inbox_path.exists():
|
|
450
|
+
content = inbox_path.read_text(encoding="utf-8")
|
|
451
|
+
if not content.endswith("\n"):
|
|
452
|
+
content += "\n"
|
|
453
|
+
content += entry
|
|
454
|
+
inbox_path.write_text(content, encoding="utf-8")
|
|
455
|
+
else:
|
|
456
|
+
inbox_path.write_text(
|
|
457
|
+
"# Inbox\n\nDrop anything here. No formatting needed. "
|
|
458
|
+
"The chat agent triages at session open.\n\n---\n\n" + entry,
|
|
459
|
+
encoding="utf-8",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
new_count = old_count + 1
|
|
463
|
+
result = {
|
|
464
|
+
"command": "inbox_add",
|
|
465
|
+
"text": text,
|
|
466
|
+
"provenance": provenance,
|
|
467
|
+
"inbox_count": {"old": old_count, "new": new_count},
|
|
468
|
+
"file": "gator-command/inbox.md",
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if isinstance(formatter, JsonFormatter):
|
|
472
|
+
print(json.dumps(result, indent=2))
|
|
473
|
+
else:
|
|
474
|
+
print(f" {GREEN}✓{NC} inbox +1 item ({new_count} total)")
|
|
475
|
+
|
|
476
|
+
return 0
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def cmd_connect(args: argparse.Namespace) -> int:
|
|
480
|
+
"""Add an annotated cross-reference between two threads."""
|
|
481
|
+
repo_root, memex_dir, role_name, role, formatter = load_role_context(args)
|
|
482
|
+
|
|
483
|
+
require_permission(role, role_name)
|
|
484
|
+
|
|
485
|
+
if not args.why or not args.why.strip():
|
|
486
|
+
print("Connection annotation is required (--why).")
|
|
487
|
+
print("Constitutional rule: cross-references annotate why the link exists.")
|
|
488
|
+
return 1
|
|
489
|
+
|
|
490
|
+
source = memex_state.resolve_thread(memex_dir, args.source)
|
|
491
|
+
target = memex_state.resolve_thread(memex_dir, args.target)
|
|
492
|
+
|
|
493
|
+
if not source:
|
|
494
|
+
print(f"Source thread not found: '{args.source}'")
|
|
495
|
+
candidates = memex_state.list_thread_names(memex_dir)
|
|
496
|
+
if candidates:
|
|
497
|
+
print(f"Available: {', '.join(candidates)}")
|
|
498
|
+
return 1
|
|
499
|
+
if not target:
|
|
500
|
+
print(f"Target thread not found: '{args.target}'")
|
|
501
|
+
candidates = memex_state.list_thread_names(memex_dir)
|
|
502
|
+
if candidates:
|
|
503
|
+
print(f"Available: {', '.join(candidates)}")
|
|
504
|
+
return 1
|
|
505
|
+
|
|
506
|
+
# Compute relative path from source's directory to target
|
|
507
|
+
rel_path = Path(os.path.relpath(target, source.parent))
|
|
508
|
+
# Normalize to forward slashes for Markdown links
|
|
509
|
+
rel_path_str = rel_path.as_posix()
|
|
510
|
+
|
|
511
|
+
target_title = memex_state.get_thread_title(target)
|
|
512
|
+
annotation = args.why.strip()
|
|
513
|
+
connection_line = f"→ [{target_title}]({rel_path_str}) — {annotation}"
|
|
514
|
+
|
|
515
|
+
# Read source file and check for duplicates
|
|
516
|
+
text = source.read_text(encoding="utf-8")
|
|
517
|
+
|
|
518
|
+
# Check if target is already referenced in Connections
|
|
519
|
+
if target.stem in text and "## Connections" in text:
|
|
520
|
+
# More precise check: is the target filename in a connection line?
|
|
521
|
+
conn_section_start = text.index("## Connections")
|
|
522
|
+
conn_section = text[conn_section_start:]
|
|
523
|
+
# Find end of connections section (next ## or EOF)
|
|
524
|
+
next_heading = re.search(r"\n## ", conn_section[len("## Connections"):])
|
|
525
|
+
if next_heading:
|
|
526
|
+
conn_section = conn_section[:len("## Connections") + next_heading.start()]
|
|
527
|
+
if target.name in conn_section or target.stem in conn_section:
|
|
528
|
+
result = {
|
|
529
|
+
"command": "connect",
|
|
530
|
+
"from": {"thread": source.stem, "file": str(source.relative_to(repo_root))},
|
|
531
|
+
"to": {"thread": target.stem, "file": str(target.relative_to(repo_root))},
|
|
532
|
+
"annotation": annotation,
|
|
533
|
+
"already_existed": True,
|
|
534
|
+
}
|
|
535
|
+
if isinstance(formatter, JsonFormatter):
|
|
536
|
+
print(json.dumps(result, indent=2))
|
|
537
|
+
else:
|
|
538
|
+
print(f" {DIM}Connection already exists: {source.stem} → {target.stem}{NC}")
|
|
539
|
+
return 0
|
|
540
|
+
|
|
541
|
+
# Insert the connection line
|
|
542
|
+
if "## Connections" in text:
|
|
543
|
+
# Find the end of the Connections section (next ## heading or EOF)
|
|
544
|
+
lines = text.splitlines(keepends=True)
|
|
545
|
+
conn_idx = None
|
|
546
|
+
insert_idx = None
|
|
547
|
+
for i, line in enumerate(lines):
|
|
548
|
+
if line.strip() == "## Connections":
|
|
549
|
+
conn_idx = i
|
|
550
|
+
elif conn_idx is not None and line.startswith("## "):
|
|
551
|
+
# Insert before this heading, with a blank line
|
|
552
|
+
insert_idx = i
|
|
553
|
+
break
|
|
554
|
+
if conn_idx is not None and insert_idx is None:
|
|
555
|
+
insert_idx = len(lines) # Connections is the last section
|
|
556
|
+
|
|
557
|
+
if insert_idx is not None:
|
|
558
|
+
# Insert before the next heading (or at end), after the last connection line
|
|
559
|
+
# Walk backwards from insert_idx to find last non-blank line in section
|
|
560
|
+
actual_insert = insert_idx
|
|
561
|
+
for j in range(insert_idx - 1, conn_idx, -1):
|
|
562
|
+
if lines[j].strip():
|
|
563
|
+
actual_insert = j + 1
|
|
564
|
+
break
|
|
565
|
+
lines.insert(actual_insert, connection_line + "\n")
|
|
566
|
+
text = "".join(lines)
|
|
567
|
+
else:
|
|
568
|
+
# No Connections section — insert before Open Questions, Next Up, or at end
|
|
569
|
+
insert_before = None
|
|
570
|
+
for heading in ["## Open Questions", "## Next Up"]:
|
|
571
|
+
if heading in text:
|
|
572
|
+
insert_before = heading
|
|
573
|
+
break
|
|
574
|
+
if insert_before:
|
|
575
|
+
section_block = f"## Connections\n\n{connection_line}\n\n"
|
|
576
|
+
text = text.replace(insert_before, section_block + insert_before)
|
|
577
|
+
else:
|
|
578
|
+
text = text.rstrip() + f"\n\n## Connections\n\n{connection_line}\n"
|
|
579
|
+
|
|
580
|
+
source.write_text(text, encoding="utf-8")
|
|
581
|
+
|
|
582
|
+
provenance = role.get("provenance", f"-{role_name}")
|
|
583
|
+
result = {
|
|
584
|
+
"command": "connect",
|
|
585
|
+
"from": {"thread": source.stem, "file": str(source.relative_to(repo_root))},
|
|
586
|
+
"to": {"thread": target.stem, "file": str(target.relative_to(repo_root))},
|
|
587
|
+
"annotation": annotation,
|
|
588
|
+
"relative_path": rel_path_str,
|
|
589
|
+
"provenance": provenance,
|
|
590
|
+
"already_existed": False,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if isinstance(formatter, JsonFormatter):
|
|
594
|
+
print(json.dumps(result, indent=2))
|
|
595
|
+
else:
|
|
596
|
+
print(f" {GREEN}✓{NC} connected {BOLD}{source.stem}{NC} → {BOLD}{target.stem}{NC}")
|
|
597
|
+
print(f" \"{annotation}\"")
|
|
598
|
+
|
|
599
|
+
return 0
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ── New commands (Phase 1 MVP) ────────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def cmd_suggest(args: argparse.Namespace) -> int:
|
|
606
|
+
"""Suggest what to work on based on graph state."""
|
|
607
|
+
repo_root = memex_state.find_repo_root(getattr(args, 'repo', None))
|
|
608
|
+
memex_dir = repo_root / "gator-command"
|
|
609
|
+
formatter = get_formatter(args.format)
|
|
610
|
+
|
|
611
|
+
threads = load_threads(memex_dir)
|
|
612
|
+
active = [t for t in threads if t.tier_label == "Active thread"]
|
|
613
|
+
|
|
614
|
+
suggestions = []
|
|
615
|
+
|
|
616
|
+
# 1. Next Up items (explicit intent — highest priority)
|
|
617
|
+
for t in active:
|
|
618
|
+
next_up = memex_state.get_thread_next_up(t.source_path)
|
|
619
|
+
if next_up:
|
|
620
|
+
for item in next_up:
|
|
621
|
+
suggestions.append({
|
|
622
|
+
"type": "next_up",
|
|
623
|
+
"thread": t.title,
|
|
624
|
+
"item": item,
|
|
625
|
+
"priority": 1,
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
# 2. Hot threads (high hits, recently touched)
|
|
629
|
+
hot = sorted(active, key=lambda t: (-t.hits, memex_state.thread_staleness(t)))
|
|
630
|
+
for t in hot[:3]:
|
|
631
|
+
if t.hits > 0:
|
|
632
|
+
suggestions.append({
|
|
633
|
+
"type": "hot",
|
|
634
|
+
"thread": t.title,
|
|
635
|
+
"hits": t.hits,
|
|
636
|
+
"staleness_days": memex_state.thread_staleness(t),
|
|
637
|
+
"priority": 2,
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
# 3. Stale threads (need attention or demotion)
|
|
641
|
+
for t in active:
|
|
642
|
+
days = memex_state.thread_staleness(t)
|
|
643
|
+
if days > 14:
|
|
644
|
+
suggestions.append({
|
|
645
|
+
"type": "stale",
|
|
646
|
+
"thread": t.title,
|
|
647
|
+
"staleness_days": days,
|
|
648
|
+
"priority": 3,
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
# 4. Inbox items waiting
|
|
652
|
+
inbox = memex_state.parse_inbox(memex_dir)
|
|
653
|
+
if inbox:
|
|
654
|
+
suggestions.append({
|
|
655
|
+
"type": "inbox",
|
|
656
|
+
"count": len(inbox),
|
|
657
|
+
"priority": 2,
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
if isinstance(formatter, JsonFormatter):
|
|
661
|
+
print(json.dumps({"suggestions": suggestions}, indent=2, default=str))
|
|
662
|
+
else:
|
|
663
|
+
if not suggestions:
|
|
664
|
+
print(f" {DIM}Nothing urgent. You're free to explore.{NC}")
|
|
665
|
+
return 0
|
|
666
|
+
|
|
667
|
+
print(f"{BOLD}Suggestions{NC}\n")
|
|
668
|
+
for s in sorted(suggestions, key=lambda x: x["priority"]):
|
|
669
|
+
if s["type"] == "next_up":
|
|
670
|
+
print(f" {CYAN}→{NC} {BOLD}{s['thread']}{NC}: {s['item']}")
|
|
671
|
+
elif s["type"] == "hot":
|
|
672
|
+
print(f" {GREEN}●{NC} {s['thread']} {DIM}(hits: {s['hits']}, {s['staleness_days']}d ago){NC}")
|
|
673
|
+
elif s["type"] == "stale":
|
|
674
|
+
print(f" {YELLOW}⚠{NC} {s['thread']} {DIM}stale ({s['staleness_days']} days) — revisit or demote?{NC}")
|
|
675
|
+
elif s["type"] == "inbox":
|
|
676
|
+
print(f" {YELLOW}▶{NC} {s['count']} inbox item(s) waiting for triage")
|
|
677
|
+
print()
|
|
678
|
+
|
|
679
|
+
return 0
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def cmd_draft(args: argparse.Namespace) -> int:
|
|
683
|
+
"""Show the current commit draft."""
|
|
684
|
+
repo_root = memex_state.find_repo_root(getattr(args, 'repo', None))
|
|
685
|
+
draft_path = repo_root / "gator-command" / "commit_draft.md"
|
|
686
|
+
|
|
687
|
+
if not draft_path.exists():
|
|
688
|
+
print(f" {DIM}No commit draft found.{NC}")
|
|
689
|
+
return 0
|
|
690
|
+
|
|
691
|
+
text = draft_path.read_text(encoding="utf-8").strip()
|
|
692
|
+
if not text:
|
|
693
|
+
print(f" {DIM}Commit draft is empty.{NC}")
|
|
694
|
+
return 0
|
|
695
|
+
|
|
696
|
+
if isinstance(get_formatter(args.format), JsonFormatter):
|
|
697
|
+
lines = [l.strip() for l in text.splitlines() if l.strip().startswith("- ")]
|
|
698
|
+
print(json.dumps({"draft": lines, "count": len(lines)}, indent=2))
|
|
699
|
+
else:
|
|
700
|
+
print(f"{BOLD}Commit Draft{NC}\n")
|
|
701
|
+
for line in text.splitlines():
|
|
702
|
+
if line.strip():
|
|
703
|
+
print(f" {line}")
|
|
704
|
+
print()
|
|
705
|
+
|
|
706
|
+
return 0
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def cmd_thread_new(args: argparse.Namespace) -> int:
|
|
710
|
+
"""Create a new thread from the template."""
|
|
711
|
+
repo_root, memex_dir, role_name, role, _ = load_role_context(args)
|
|
712
|
+
|
|
713
|
+
require_permission(role, role_name)
|
|
714
|
+
|
|
715
|
+
name = args.name.strip().lower().replace(" ", "-")
|
|
716
|
+
target = memex_dir / "active-threads" / f"{name}.md"
|
|
717
|
+
|
|
718
|
+
if target.exists():
|
|
719
|
+
print(f"Thread already exists: {target.relative_to(repo_root)}")
|
|
720
|
+
return 1
|
|
721
|
+
|
|
722
|
+
category = args.category or "systems"
|
|
723
|
+
tags = args.tags or name
|
|
724
|
+
|
|
725
|
+
content = f"""---
|
|
726
|
+
last-touched: {TODAY.isoformat()}
|
|
727
|
+
category: {category}
|
|
728
|
+
hits: 0
|
|
729
|
+
tags: [{tags}]
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
# {args.title or name.replace('-', ' ').title()}
|
|
733
|
+
|
|
734
|
+
## Summary
|
|
735
|
+
|
|
736
|
+
[2-4 sentences. What this thread is about.]
|
|
737
|
+
|
|
738
|
+
## Detail
|
|
739
|
+
|
|
740
|
+
[Content goes here.]
|
|
741
|
+
|
|
742
|
+
## Connections
|
|
743
|
+
|
|
744
|
+
[→ links to related threads]
|
|
745
|
+
"""
|
|
746
|
+
|
|
747
|
+
target.write_text(content, encoding="utf-8")
|
|
748
|
+
|
|
749
|
+
provenance = role.get("provenance", f"-{role_name}")
|
|
750
|
+
if isinstance(get_formatter(args.format), JsonFormatter):
|
|
751
|
+
print(json.dumps({
|
|
752
|
+
"command": "thread_new",
|
|
753
|
+
"name": name,
|
|
754
|
+
"file": str(target.relative_to(repo_root)),
|
|
755
|
+
"provenance": provenance,
|
|
756
|
+
}, indent=2))
|
|
757
|
+
else:
|
|
758
|
+
print(f" {GREEN}✓{NC} created {BOLD}{name}{NC}")
|
|
759
|
+
print(f" {target.relative_to(repo_root)}")
|
|
760
|
+
|
|
761
|
+
return 0
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
765
|
+
"""Initialize a new Memex project interactively or via flags."""
|
|
766
|
+
from spawn import (
|
|
767
|
+
GENERIC_CONSTITUTION, BLANK_ROADMAP, BLANK_ISSUES, BLANK_INBOX,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# Repo root: use --repo, or cwd (init may run before gator-command/ exists)
|
|
771
|
+
if args.repo:
|
|
772
|
+
repo_root = Path(args.repo).resolve()
|
|
773
|
+
else:
|
|
774
|
+
repo_root = Path.cwd()
|
|
775
|
+
|
|
776
|
+
memex_dir = repo_root / "gator-command"
|
|
777
|
+
formatter = get_formatter(args.format)
|
|
778
|
+
is_json = isinstance(formatter, JsonFormatter)
|
|
779
|
+
|
|
780
|
+
# Already-initialized detection
|
|
781
|
+
identity_path = memex_dir / "identity.md"
|
|
782
|
+
if identity_path.exists() and not getattr(args, 'force', False):
|
|
783
|
+
text = identity_path.read_text(encoding="utf-8")
|
|
784
|
+
if "[Your role" not in text and "[your " not in text.lower() and "[Add more" not in text:
|
|
785
|
+
if is_json:
|
|
786
|
+
print(json.dumps({"command": "init", "error": "already_initialized"}, indent=2))
|
|
787
|
+
else:
|
|
788
|
+
print(f"\n {YELLOW}Already initialized.{NC} Run {BOLD}memex status{NC} to see where things stand.")
|
|
789
|
+
print(f" Use {DIM}memex init --force{NC} to re-initialize.\n")
|
|
790
|
+
return 1
|
|
791
|
+
|
|
792
|
+
# Gather answers
|
|
793
|
+
project_name = getattr(args, 'name', None)
|
|
794
|
+
building = getattr(args, 'building', None)
|
|
795
|
+
pi_name = getattr(args, 'pi', None)
|
|
796
|
+
|
|
797
|
+
# JSON mode requires all flags (no interactive prompts)
|
|
798
|
+
if is_json and not (project_name and building and pi_name):
|
|
799
|
+
print(json.dumps({
|
|
800
|
+
"command": "init",
|
|
801
|
+
"error": "missing_flags",
|
|
802
|
+
"message": "JSON mode requires --name, --building, and --pi flags",
|
|
803
|
+
}, indent=2))
|
|
804
|
+
return 1
|
|
805
|
+
|
|
806
|
+
# Interactive prompts
|
|
807
|
+
if not (project_name and building and pi_name):
|
|
808
|
+
print(f"\n {BOLD}Welcome to the Memex.{NC}")
|
|
809
|
+
print(f" Three questions and you're up and running.\n")
|
|
810
|
+
|
|
811
|
+
if not project_name:
|
|
812
|
+
project_name = input(f" {BOLD}What's this project called?{NC} ").strip()
|
|
813
|
+
if not building:
|
|
814
|
+
building = input(f"\n {BOLD}In one sentence, what are you building or researching?{NC}\n ").strip()
|
|
815
|
+
if not pi_name:
|
|
816
|
+
pi_name = input(f"\n {BOLD}What's your name?{NC} ").strip()
|
|
817
|
+
print()
|
|
818
|
+
|
|
819
|
+
if not project_name or not building or not pi_name:
|
|
820
|
+
print(f" {RED}All three answers are needed.{NC}")
|
|
821
|
+
return 1
|
|
822
|
+
|
|
823
|
+
today = TODAY.isoformat()
|
|
824
|
+
created = []
|
|
825
|
+
skipped = []
|
|
826
|
+
|
|
827
|
+
# ── Create directory structure ──────────────────────────────────────────
|
|
828
|
+
dirs = [
|
|
829
|
+
"gator-command/active-threads", "gator-command/threads", "gator-command/artifacts",
|
|
830
|
+
"gator-command/vault", "gator-command/procedures", "gator-command/reference-notes",
|
|
831
|
+
"gator-command/patterns",
|
|
832
|
+
"blueprints", "docs/reports", "docs/wiki",
|
|
833
|
+
]
|
|
834
|
+
for d in dirs:
|
|
835
|
+
(repo_root / d).mkdir(parents=True, exist_ok=True)
|
|
836
|
+
|
|
837
|
+
# ── Helper: write file if missing or placeholder ────────────────────────
|
|
838
|
+
def write_if_needed(rel_path: str, content: str) -> None:
|
|
839
|
+
path = repo_root / rel_path
|
|
840
|
+
if path.exists() and not getattr(args, 'force', False):
|
|
841
|
+
existing = path.read_text(encoding="utf-8")
|
|
842
|
+
# Skip if file has real content (not a placeholder)
|
|
843
|
+
if existing.strip() and "[Your role" not in existing and "[What this" not in existing:
|
|
844
|
+
skipped.append(rel_path)
|
|
845
|
+
return
|
|
846
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
847
|
+
path.write_text(content, encoding="utf-8")
|
|
848
|
+
created.append(rel_path)
|
|
849
|
+
|
|
850
|
+
# ── Knowledge-layer files ───────────────────────────────────────────────
|
|
851
|
+
|
|
852
|
+
# Identity — populated with PI info
|
|
853
|
+
write_if_needed("gator-command/identity.md", f"""\
|
|
854
|
+
---
|
|
855
|
+
operating-mode: user
|
|
856
|
+
---
|
|
857
|
+
|
|
858
|
+
# Identity
|
|
859
|
+
|
|
860
|
+
{pi_name}. Working on {project_name}.
|
|
861
|
+
|
|
862
|
+
[Add more here — your background, how you think, what you're good at.
|
|
863
|
+
The agent uses this to calibrate how it collaborates with you.]
|
|
864
|
+
""")
|
|
865
|
+
|
|
866
|
+
# Mission — populated with what they're building
|
|
867
|
+
write_if_needed("gator-command/mission.md", f"""\
|
|
868
|
+
# Mission
|
|
869
|
+
|
|
870
|
+
## What We're Building
|
|
871
|
+
|
|
872
|
+
{building}
|
|
873
|
+
|
|
874
|
+
## What Success Looks Like
|
|
875
|
+
|
|
876
|
+
[How you'll know it's working.]
|
|
877
|
+
""")
|
|
878
|
+
|
|
879
|
+
# Roadmap, issues, inbox — blank structures
|
|
880
|
+
write_if_needed("gator-command/roadmap.md", BLANK_ROADMAP.format(date=today))
|
|
881
|
+
write_if_needed("gator-command/issues.md", BLANK_ISSUES)
|
|
882
|
+
write_if_needed("gator-command/inbox.md", BLANK_INBOX)
|
|
883
|
+
|
|
884
|
+
# Commit draft — empty
|
|
885
|
+
draft_path = repo_root / "gator-command" / "commit_draft.md"
|
|
886
|
+
if not draft_path.exists():
|
|
887
|
+
draft_path.write_text("", encoding="utf-8")
|
|
888
|
+
created.append("gator-command/commit_draft.md")
|
|
889
|
+
|
|
890
|
+
# ── Constitution ────────────────────────────────────────────────────────
|
|
891
|
+
|
|
892
|
+
# constitution.md: generate with project name
|
|
893
|
+
const_path = repo_root / "constitution.md"
|
|
894
|
+
if not const_path.exists() or getattr(args, 'force', False):
|
|
895
|
+
const_content = GENERIC_CONSTITUTION.format(project_name=project_name)
|
|
896
|
+
const_path.write_text(const_content, encoding="utf-8")
|
|
897
|
+
created.append("constitution.md")
|
|
898
|
+
else:
|
|
899
|
+
skipped.append("constitution.md")
|
|
900
|
+
|
|
901
|
+
# ── Entry point files ───────────────────────────────────────────────────
|
|
902
|
+
|
|
903
|
+
const_refs = "[`constitution.md`](constitution.md)"
|
|
904
|
+
|
|
905
|
+
write_if_needed("CLAUDE.md", f"""\
|
|
906
|
+
# Claude Code Entry Point
|
|
907
|
+
|
|
908
|
+
Your default role is **agent** (`--role agent`).
|
|
909
|
+
|
|
910
|
+
You MUST read {const_refs}, and execute the session-opening procedure before your first response. Do not use MEMORY.md or any other auto-loaded context as a substitute — the constitution governs this project.
|
|
911
|
+
""")
|
|
912
|
+
|
|
913
|
+
write_if_needed("AGENTS.md", f"""\
|
|
914
|
+
# Agent Entry Point
|
|
915
|
+
|
|
916
|
+
Your default role is **enforcer** (`--role enforcer`).
|
|
917
|
+
|
|
918
|
+
You MUST read {const_refs}, and execute the session-opening procedure before your first response. Do not use MEMORY.md or any other auto-loaded context as a substitute — the constitution governs this project.
|
|
919
|
+
""")
|
|
920
|
+
|
|
921
|
+
write_if_needed("GEMINI.md", f"""\
|
|
922
|
+
# Gemini Entry Point
|
|
923
|
+
|
|
924
|
+
Your default role is **enforcer** (`--role enforcer`).
|
|
925
|
+
|
|
926
|
+
You MUST read {const_refs}, and execute the session-opening procedure before your first response. Do not use MEMORY.md or any other auto-loaded context as a substitute — the constitution governs this project.
|
|
927
|
+
""")
|
|
928
|
+
|
|
929
|
+
# ── Templates ───────────────────────────────────────────────────────────
|
|
930
|
+
|
|
931
|
+
write_if_needed("gator-command/active-threads/_TEMPLATE.md", """\
|
|
932
|
+
---
|
|
933
|
+
last-touched: YYYY-MM-DD
|
|
934
|
+
category: choose-one [mathematics, cognition, systems, ventures, economics, civic]
|
|
935
|
+
hits: 0
|
|
936
|
+
tags: [relevant, keywords]
|
|
937
|
+
---
|
|
938
|
+
|
|
939
|
+
# Thread Title
|
|
940
|
+
|
|
941
|
+
## Summary
|
|
942
|
+
|
|
943
|
+
2-4 sentences. Self-contained, readable without the rest of the thread.
|
|
944
|
+
|
|
945
|
+
## Detail
|
|
946
|
+
|
|
947
|
+
Dense index-card content. Domain-specific subheadings optional.
|
|
948
|
+
|
|
949
|
+
## Connections
|
|
950
|
+
|
|
951
|
+
→ [Related Thread](relative-path.md) — why this link exists
|
|
952
|
+
|
|
953
|
+
## Open Questions
|
|
954
|
+
|
|
955
|
+
- Questions that remain unresolved
|
|
956
|
+
|
|
957
|
+
## Next Up
|
|
958
|
+
|
|
959
|
+
- Forward intent for the next session (optional — only write when clear intent exists)
|
|
960
|
+
""")
|
|
961
|
+
|
|
962
|
+
write_if_needed("gator-command/artifacts/_TEMPLATE.md", """\
|
|
963
|
+
---
|
|
964
|
+
date: YYYY-MM-DD
|
|
965
|
+
depth: full | stub
|
|
966
|
+
tags: [relevant, keywords]
|
|
967
|
+
source-thread: relative/path/to/thread.md
|
|
968
|
+
source: (optional) path or URL to the original external material
|
|
969
|
+
summary: One to two sentences.
|
|
970
|
+
---
|
|
971
|
+
|
|
972
|
+
# Artifact Title
|
|
973
|
+
|
|
974
|
+
Content goes here. Artifacts at depth: full are self-contained.
|
|
975
|
+
Artifacts at depth: stub contain a summary and a pointer to external material.
|
|
976
|
+
""")
|
|
977
|
+
|
|
978
|
+
write_if_needed("blueprints/_TEMPLATE.md", """\
|
|
979
|
+
---
|
|
980
|
+
title: Blueprint Title
|
|
981
|
+
status: active | deprecated | experimental
|
|
982
|
+
last-updated: YYYY-MM-DD
|
|
983
|
+
owner: architect
|
|
984
|
+
tags: [relevant, keywords]
|
|
985
|
+
source-thread: gator-command/active-threads/thread-name.md
|
|
986
|
+
---
|
|
987
|
+
|
|
988
|
+
# Blueprint Title
|
|
989
|
+
|
|
990
|
+
## Overview
|
|
991
|
+
|
|
992
|
+
2-4 sentences. What this subsystem is, what it does, how it fits.
|
|
993
|
+
|
|
994
|
+
## Connections
|
|
995
|
+
|
|
996
|
+
→ [Thread Name](../gator-command/active-threads/thread-name.md) — relationship
|
|
997
|
+
""")
|
|
998
|
+
|
|
999
|
+
# ── Vault + gitignore ───────────────────────────────────────────────────
|
|
1000
|
+
|
|
1001
|
+
gitkeep = repo_root / "gator-command" / "vault" / ".gitkeep"
|
|
1002
|
+
if not gitkeep.exists():
|
|
1003
|
+
gitkeep.write_text("", encoding="utf-8")
|
|
1004
|
+
|
|
1005
|
+
gitignore = repo_root / ".gitignore"
|
|
1006
|
+
vault_rule = "gator-command/vault/**"
|
|
1007
|
+
if gitignore.exists():
|
|
1008
|
+
gi_text = gitignore.read_text(encoding="utf-8")
|
|
1009
|
+
if vault_rule not in gi_text:
|
|
1010
|
+
with open(gitignore, "a", encoding="utf-8") as f:
|
|
1011
|
+
f.write(f"\n{vault_rule}\n!gator-command/vault/.gitkeep\n")
|
|
1012
|
+
created.append(".gitignore (updated)")
|
|
1013
|
+
else:
|
|
1014
|
+
gitignore.write_text(f"{vault_rule}\n!gator-command/vault/.gitkeep\n", encoding="utf-8")
|
|
1015
|
+
created.append(".gitignore")
|
|
1016
|
+
|
|
1017
|
+
# ── Output ──────────────────────────────────────────────────────────────
|
|
1018
|
+
|
|
1019
|
+
if is_json:
|
|
1020
|
+
print(json.dumps({
|
|
1021
|
+
"command": "init",
|
|
1022
|
+
"project": project_name,
|
|
1023
|
+
"pi": pi_name,
|
|
1024
|
+
"building": building,
|
|
1025
|
+
"files_created": created,
|
|
1026
|
+
"files_skipped": skipped,
|
|
1027
|
+
}, indent=2))
|
|
1028
|
+
else:
|
|
1029
|
+
print(f" {GREEN}✓{NC} {BOLD}{project_name}{NC} is ready.\n")
|
|
1030
|
+
if created:
|
|
1031
|
+
for f in created:
|
|
1032
|
+
print(f" {GREEN}+{NC} {f}")
|
|
1033
|
+
if skipped:
|
|
1034
|
+
print()
|
|
1035
|
+
for f in skipped:
|
|
1036
|
+
print(f" {DIM}~ {f} (kept existing){NC}")
|
|
1037
|
+
print(f"\n {BOLD}Next steps:{NC}")
|
|
1038
|
+
print(f" memex status See where things stand")
|
|
1039
|
+
print(f" memex explain Learn what the Memex can do")
|
|
1040
|
+
print(f" Start an AI session — the agent picks up from here.\n")
|
|
1041
|
+
|
|
1042
|
+
return 0
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def cmd_explain(args: argparse.Namespace) -> int:
|
|
1046
|
+
"""Explain what the Memex can do."""
|
|
1047
|
+
topic = args.topic if hasattr(args, 'topic') and args.topic else "overview"
|
|
1048
|
+
topic = topic.lower()
|
|
1049
|
+
|
|
1050
|
+
explanations = {
|
|
1051
|
+
"overview": f"""
|
|
1052
|
+
{BOLD}The Memex{NC} — a knowledge system that maintains itself.
|
|
1053
|
+
|
|
1054
|
+
Your AI sessions build on each other instead of starting from zero.
|
|
1055
|
+
The Memex captures what you learn, what you decide, and what you
|
|
1056
|
+
intend to do next — as a navigable graph, not a chat log.
|
|
1057
|
+
|
|
1058
|
+
{BOLD}Quick start:{NC}
|
|
1059
|
+
memex status See where things stand
|
|
1060
|
+
memex search "topic" Find anything in your knowledge graph
|
|
1061
|
+
memex suggest Get a prioritized recommendation
|
|
1062
|
+
memex inbox add "..." Capture a thought without stopping
|
|
1063
|
+
|
|
1064
|
+
{BOLD}Learn more:{NC}
|
|
1065
|
+
memex explain threads How knowledge is organized
|
|
1066
|
+
memex explain health How graph quality is measured
|
|
1067
|
+
memex explain commands All available commands
|
|
1068
|
+
""",
|
|
1069
|
+
"threads": f"""
|
|
1070
|
+
{BOLD}Threads{NC} — topics with momentum.
|
|
1071
|
+
|
|
1072
|
+
A thread captures something you're working on or thinking about.
|
|
1073
|
+
It has a summary, connections to related threads, and a "Next Up"
|
|
1074
|
+
section for forward intent.
|
|
1075
|
+
|
|
1076
|
+
Three tiers:
|
|
1077
|
+
{GREEN}active-threads/{NC} Live working set (always loaded, 5-8 files)
|
|
1078
|
+
{DIM}threads/{NC} Dormant topics (on demand, compressed)
|
|
1079
|
+
{DIM}artifacts/{NC} Deep records (dated, historical)
|
|
1080
|
+
|
|
1081
|
+
Threads move between tiers based on activity. Hot threads promote.
|
|
1082
|
+
Cool threads demote. Nothing is deleted — demotion moves threads intact, no information is lost.
|
|
1083
|
+
|
|
1084
|
+
{BOLD}Commands:{NC}
|
|
1085
|
+
memex read thread <name> Read a thread
|
|
1086
|
+
memex thread new <name> Create a new thread
|
|
1087
|
+
memex hit <name> Mark a thread as actively discussed
|
|
1088
|
+
memex connect A B --why "..." Link two threads
|
|
1089
|
+
""",
|
|
1090
|
+
"health": f"""
|
|
1091
|
+
{BOLD}Graph Health{NC} — structural quality of your knowledge graph.
|
|
1092
|
+
|
|
1093
|
+
Five dimensions, each scored 0-100:
|
|
1094
|
+
|
|
1095
|
+
{BOLD}Navigability{NC} Can you reach any thread within 3 hops?
|
|
1096
|
+
{BOLD}Resilience{NC} Can you remove any edge without disconnecting the graph?
|
|
1097
|
+
{BOLD}Connectivity{NC} Does every thread have inbound links from its neighbors?
|
|
1098
|
+
{BOLD}Efficiency{NC} Is the graph appropriately connected (not too sparse, not too dense)?
|
|
1099
|
+
{BOLD}Legibility{NC} Can peripheral threads reach well-connected hubs?
|
|
1100
|
+
|
|
1101
|
+
Overall: HEALTHY (≥80), FAIR (50-79), UNHEALTHY (<50).
|
|
1102
|
+
|
|
1103
|
+
{BOLD}Commands:{NC}
|
|
1104
|
+
memex health Full health report
|
|
1105
|
+
memex crawl Automated maintenance (dry-run)
|
|
1106
|
+
memex crawl --fix Propose fixes via a different AI model
|
|
1107
|
+
""",
|
|
1108
|
+
"commands": f"""
|
|
1109
|
+
{BOLD}All Commands{NC}
|
|
1110
|
+
|
|
1111
|
+
{BOLD}Read:{NC}
|
|
1112
|
+
memex status Graph health, inbox, active threads
|
|
1113
|
+
memex status --full Complete state dump (Next Up, summaries)
|
|
1114
|
+
memex search "query" Full-text search across the graph
|
|
1115
|
+
memex read <type> <name> Render a thread/artifact/blueprint
|
|
1116
|
+
memex suggest Prioritized work recommendation
|
|
1117
|
+
memex draft Show current commit draft
|
|
1118
|
+
memex explain [topic] This help system
|
|
1119
|
+
|
|
1120
|
+
{BOLD}Write:{NC}
|
|
1121
|
+
memex hit <name> Increment hit count + update last-touched
|
|
1122
|
+
memex inbox add "text" Capture a thought
|
|
1123
|
+
memex connect A B --why Add annotated cross-reference
|
|
1124
|
+
memex thread new <name> Create a new thread
|
|
1125
|
+
|
|
1126
|
+
{BOLD}Maintain:{NC}
|
|
1127
|
+
memex health Graph health report (5 dimensions)
|
|
1128
|
+
memex crawl Automated health triage (dry-run)
|
|
1129
|
+
memex crawl --fix Propose fixes via different AI model
|
|
1130
|
+
|
|
1131
|
+
{BOLD}Create:{NC}
|
|
1132
|
+
memex init Set up a new Memex project
|
|
1133
|
+
memex spawn <name> --threads "a,b" New repo from seed threads
|
|
1134
|
+
|
|
1135
|
+
{BOLD}Flags:{NC}
|
|
1136
|
+
--format json Structured output for agents
|
|
1137
|
+
--role <name> Operator role (pi, agent, enforcer, crawler)
|
|
1138
|
+
--repo <path> Operate on a different Memex repo
|
|
1139
|
+
""",
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
text = explanations.get(topic)
|
|
1143
|
+
if text:
|
|
1144
|
+
print(text)
|
|
1145
|
+
else:
|
|
1146
|
+
print(f" Unknown topic: {topic}")
|
|
1147
|
+
print(f" Available: {', '.join(explanations.keys())}")
|
|
1148
|
+
return 1
|
|
1149
|
+
|
|
1150
|
+
return 0
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
# ── Script wrappers (thin shims to standalone scripts) ───────────────────────
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def cmd_health(args: argparse.Namespace) -> int:
|
|
1157
|
+
"""Run graph health check and optionally generate PNG + JSON."""
|
|
1158
|
+
import subprocess
|
|
1159
|
+
repo_root = memex_state.find_repo_root(getattr(args, 'repo', None))
|
|
1160
|
+
cmd = [sys.executable, str(SCRIPTS_DIR / "graph_health.py"),
|
|
1161
|
+
"--repo-root", str(repo_root)]
|
|
1162
|
+
if args.image:
|
|
1163
|
+
cmd.extend(["--image", args.image])
|
|
1164
|
+
if args.json_out:
|
|
1165
|
+
cmd.extend(["--json", args.json_out])
|
|
1166
|
+
if args.graph:
|
|
1167
|
+
cmd.extend(["--graph", args.graph])
|
|
1168
|
+
return subprocess.run(cmd).returncode
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def cmd_crawl(args: argparse.Namespace) -> int:
|
|
1172
|
+
"""Run the crawler (graph health triage + optional Sonnet fixes)."""
|
|
1173
|
+
import subprocess
|
|
1174
|
+
cmd = [sys.executable, str(SCRIPTS_DIR / "crawler.py")]
|
|
1175
|
+
if args.fix:
|
|
1176
|
+
cmd.append("--fix")
|
|
1177
|
+
if args.graph:
|
|
1178
|
+
cmd.extend(["--graph", args.graph])
|
|
1179
|
+
if args.no_branch:
|
|
1180
|
+
cmd.append("--no-branch")
|
|
1181
|
+
return subprocess.run(cmd).returncode
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
# ── Main entry point ─────────────────────────────────────────────────────────
|
|
1185
|
+
|
|
1186
|
+
def main() -> int:
|
|
1187
|
+
# Ensure UTF-8 output on Windows
|
|
1188
|
+
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
|
1189
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
1190
|
+
|
|
1191
|
+
# Shared args via parent parser
|
|
1192
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
1193
|
+
common.add_argument(
|
|
1194
|
+
"--format", choices=["pretty", "json"], default="pretty",
|
|
1195
|
+
help="Output format (default: pretty)",
|
|
1196
|
+
)
|
|
1197
|
+
common.add_argument(
|
|
1198
|
+
"--role", default=None,
|
|
1199
|
+
help="Operator role (pi, agent, enforcer, crawler)",
|
|
1200
|
+
)
|
|
1201
|
+
common.add_argument(
|
|
1202
|
+
"--repo", default=None,
|
|
1203
|
+
help="Path to a Memex repo (default: auto-detect from script location)",
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
parser = argparse.ArgumentParser(
|
|
1207
|
+
prog="memex",
|
|
1208
|
+
description="Git-style CLI for the Memex knowledge graph.",
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
1212
|
+
|
|
1213
|
+
# status
|
|
1214
|
+
status_parser = subparsers.add_parser("status", parents=[common],
|
|
1215
|
+
help="Memex status summary")
|
|
1216
|
+
status_parser.add_argument(
|
|
1217
|
+
"--full", action="store_true",
|
|
1218
|
+
help="Full session-opening dump (inbox items, Next Up, summaries)",
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
# search
|
|
1222
|
+
search_parser = subparsers.add_parser("search", parents=[common],
|
|
1223
|
+
help="Full-text search across the graph")
|
|
1224
|
+
search_parser.add_argument("query", help="Search query")
|
|
1225
|
+
|
|
1226
|
+
# read
|
|
1227
|
+
read_parser = subparsers.add_parser("read", parents=[common],
|
|
1228
|
+
help="Read a thread, artifact, or reference note")
|
|
1229
|
+
read_parser.add_argument("type", choices=["thread", "artifact", "reference-note", "procedure", "blueprint", "system", "report"],
|
|
1230
|
+
help="Type of document to read")
|
|
1231
|
+
read_parser.add_argument("name", help="Name (or partial name) of the document")
|
|
1232
|
+
read_parser.add_argument("--section", default=None,
|
|
1233
|
+
help="Show only a specific H2 section (by heading name)")
|
|
1234
|
+
|
|
1235
|
+
# hit
|
|
1236
|
+
hit_parser = subparsers.add_parser("hit", parents=[common],
|
|
1237
|
+
help="Increment hit count and update last-touched")
|
|
1238
|
+
hit_parser.add_argument("name", help="Thread name (exact or partial)")
|
|
1239
|
+
|
|
1240
|
+
# inbox
|
|
1241
|
+
inbox_parser = subparsers.add_parser("inbox", parents=[common],
|
|
1242
|
+
help="Inbox operations")
|
|
1243
|
+
inbox_sub = inbox_parser.add_subparsers(dest="inbox_command")
|
|
1244
|
+
inbox_add = inbox_sub.add_parser("add", parents=[common],
|
|
1245
|
+
help="Add an item to the inbox")
|
|
1246
|
+
inbox_add.add_argument("text", help="Item text to add")
|
|
1247
|
+
|
|
1248
|
+
# spawn
|
|
1249
|
+
spawn_parser = subparsers.add_parser("spawn", parents=[common],
|
|
1250
|
+
help="Create a new Memex repo from seed threads")
|
|
1251
|
+
spawn_parser.add_argument("name", help="Project name (becomes directory name)")
|
|
1252
|
+
spawn_parser.add_argument("--threads", required=True,
|
|
1253
|
+
help="Comma-separated list of thread names to seed")
|
|
1254
|
+
spawn_parser.add_argument("--dry-run", action="store_true",
|
|
1255
|
+
help="Show what would be copied without creating anything")
|
|
1256
|
+
|
|
1257
|
+
# health
|
|
1258
|
+
health_parser = subparsers.add_parser("health", parents=[common],
|
|
1259
|
+
help="Run graph health check (PNG, JSON)")
|
|
1260
|
+
health_parser.add_argument("--image", default=None,
|
|
1261
|
+
help="Output path for graph PNG (e.g., docs/wiki/thread-graph.png)")
|
|
1262
|
+
health_parser.add_argument("--json-out", default=None, metavar="PATH",
|
|
1263
|
+
help="Output path for health JSON")
|
|
1264
|
+
health_parser.add_argument("--graph", default=None,
|
|
1265
|
+
help="Filter by graph namespace (e.g., 'design', 'user')")
|
|
1266
|
+
|
|
1267
|
+
# crawl
|
|
1268
|
+
crawl_parser = subparsers.add_parser("crawl", parents=[common],
|
|
1269
|
+
help="Run crawler (health triage + optional Sonnet fixes)")
|
|
1270
|
+
crawl_parser.add_argument("--fix", action="store_true",
|
|
1271
|
+
help="Invoke Sonnet to propose fixes (requires ANTHROPIC_API_KEY)")
|
|
1272
|
+
crawl_parser.add_argument("--graph", default=None,
|
|
1273
|
+
help="Filter by graph namespace")
|
|
1274
|
+
crawl_parser.add_argument("--no-branch", action="store_true",
|
|
1275
|
+
help="In fix mode, don't create a git branch")
|
|
1276
|
+
|
|
1277
|
+
# connect
|
|
1278
|
+
connect_parser = subparsers.add_parser("connect", parents=[common],
|
|
1279
|
+
help="Add annotated cross-reference between threads")
|
|
1280
|
+
connect_parser.add_argument("source", metavar="from", help="Source thread name")
|
|
1281
|
+
connect_parser.add_argument("target", metavar="to", help="Target thread name")
|
|
1282
|
+
connect_parser.add_argument("--why", required=True, help="Annotation: why this link exists")
|
|
1283
|
+
|
|
1284
|
+
# suggest
|
|
1285
|
+
subparsers.add_parser("suggest", parents=[common],
|
|
1286
|
+
help="Suggest what to work on next")
|
|
1287
|
+
|
|
1288
|
+
# draft
|
|
1289
|
+
subparsers.add_parser("draft", parents=[common],
|
|
1290
|
+
help="Show the current commit draft")
|
|
1291
|
+
|
|
1292
|
+
# thread new
|
|
1293
|
+
thread_parser = subparsers.add_parser("thread", parents=[common],
|
|
1294
|
+
help="Thread operations")
|
|
1295
|
+
thread_sub = thread_parser.add_subparsers(dest="thread_command")
|
|
1296
|
+
thread_new = thread_sub.add_parser("new", parents=[common],
|
|
1297
|
+
help="Create a new thread")
|
|
1298
|
+
thread_new.add_argument("name", help="Thread name (will be slugified)")
|
|
1299
|
+
thread_new.add_argument("--title", default=None, help="Thread title (default: derived from name)")
|
|
1300
|
+
thread_new.add_argument("--category", default=None, help="Category (default: systems)")
|
|
1301
|
+
thread_new.add_argument("--tags", default=None, help="Comma-separated tags")
|
|
1302
|
+
|
|
1303
|
+
# peek
|
|
1304
|
+
subparsers.add_parser("peek", parents=[common],
|
|
1305
|
+
help="Quick cross-galaxy orientation: health, threads, roadmap, issues, last commit")
|
|
1306
|
+
|
|
1307
|
+
# init
|
|
1308
|
+
init_parser = subparsers.add_parser("init", parents=[common],
|
|
1309
|
+
help="Initialize a new Memex project")
|
|
1310
|
+
init_parser.add_argument("--name", default=None,
|
|
1311
|
+
help="Project name (skips interactive prompt)")
|
|
1312
|
+
init_parser.add_argument("--building", default=None,
|
|
1313
|
+
help="What you're building (skips interactive prompt)")
|
|
1314
|
+
init_parser.add_argument("--pi", default=None,
|
|
1315
|
+
help="Your name (skips interactive prompt)")
|
|
1316
|
+
init_parser.add_argument("--force", action="store_true",
|
|
1317
|
+
help="Re-initialize even if already set up")
|
|
1318
|
+
|
|
1319
|
+
# explain
|
|
1320
|
+
explain_parser = subparsers.add_parser("explain", parents=[common],
|
|
1321
|
+
help="Explain what the Memex can do")
|
|
1322
|
+
explain_parser.add_argument("topic", nargs="?", default="overview",
|
|
1323
|
+
help="Topic: overview, threads, health, commands")
|
|
1324
|
+
|
|
1325
|
+
args = parser.parse_args()
|
|
1326
|
+
|
|
1327
|
+
if args.command is None:
|
|
1328
|
+
parser.print_help()
|
|
1329
|
+
return 0
|
|
1330
|
+
|
|
1331
|
+
def cmd_spawn(args):
|
|
1332
|
+
from spawn import run_spawn
|
|
1333
|
+
thread_list = [t.strip() for t in args.threads.split(",")]
|
|
1334
|
+
return run_spawn(args.name, thread_list, dry_run=args.dry_run, fmt=args.format)
|
|
1335
|
+
|
|
1336
|
+
def cmd_thread(args):
|
|
1337
|
+
if getattr(args, 'thread_command', None) == "new":
|
|
1338
|
+
return cmd_thread_new(args)
|
|
1339
|
+
print("Usage: memex thread new <name>")
|
|
1340
|
+
return 1
|
|
1341
|
+
|
|
1342
|
+
def cmd_peek(args):
|
|
1343
|
+
"""Quick cross-galaxy orientation: roadmap, issues, threads, health, inbox, last commit."""
|
|
1344
|
+
import subprocess
|
|
1345
|
+
|
|
1346
|
+
repo_root = memex_state.find_repo_root(getattr(args, "repo", None))
|
|
1347
|
+
memex_dir = repo_root / "gator-command"
|
|
1348
|
+
fmt = get_formatter(args)
|
|
1349
|
+
|
|
1350
|
+
# Graph health
|
|
1351
|
+
threads = load_threads(memex_dir)
|
|
1352
|
+
health_data = None
|
|
1353
|
+
try:
|
|
1354
|
+
from graph_health import build_graph, compute_health
|
|
1355
|
+
G, path_to_title = build_graph(memex_dir, threads)
|
|
1356
|
+
health_data = compute_health(G, path_to_title)
|
|
1357
|
+
except Exception:
|
|
1358
|
+
health_data = {"verdict": "UNKNOWN", "overall": 0, "nodes": len(threads), "edges": 0}
|
|
1359
|
+
|
|
1360
|
+
# Inbox
|
|
1361
|
+
inbox = memex_state.parse_inbox(memex_dir)
|
|
1362
|
+
|
|
1363
|
+
# Active threads (sorted by staleness)
|
|
1364
|
+
active = [t for t in threads if t.tier_label.lower().startswith("active")]
|
|
1365
|
+
active.sort(key=lambda t: (memex_state.thread_staleness(t), -t.hits, t.title.lower()))
|
|
1366
|
+
thread_data = [{
|
|
1367
|
+
"title": t.title,
|
|
1368
|
+
"hits": t.hits,
|
|
1369
|
+
"last_touched": t.last_touched,
|
|
1370
|
+
"staleness_days": memex_state.thread_staleness(t),
|
|
1371
|
+
"category": t.category,
|
|
1372
|
+
} for t in active]
|
|
1373
|
+
|
|
1374
|
+
# Roadmap summary (first 20 non-empty lines after the header)
|
|
1375
|
+
roadmap_path = memex_dir / "roadmap.md"
|
|
1376
|
+
roadmap_summary = None
|
|
1377
|
+
if roadmap_path.exists():
|
|
1378
|
+
lines = roadmap_path.read_text(encoding="utf-8").splitlines()
|
|
1379
|
+
content_lines = [l for l in lines if l.strip() and not l.startswith("---")]
|
|
1380
|
+
roadmap_summary = "\n".join(content_lines[:25])
|
|
1381
|
+
|
|
1382
|
+
# Issues summary
|
|
1383
|
+
issues_path = memex_dir / "issues.md"
|
|
1384
|
+
issues_summary = None
|
|
1385
|
+
if issues_path.exists():
|
|
1386
|
+
lines = issues_path.read_text(encoding="utf-8").splitlines()
|
|
1387
|
+
content_lines = [l for l in lines if l.strip() and not l.startswith("---")]
|
|
1388
|
+
issues_summary = "\n".join(content_lines[:20])
|
|
1389
|
+
|
|
1390
|
+
# Last git commit
|
|
1391
|
+
last_commit = None
|
|
1392
|
+
try:
|
|
1393
|
+
result = subprocess.run(
|
|
1394
|
+
["git", "log", "--oneline", "-3"],
|
|
1395
|
+
cwd=str(repo_root), capture_output=True, text=True, timeout=5
|
|
1396
|
+
)
|
|
1397
|
+
if result.returncode == 0:
|
|
1398
|
+
last_commit = result.stdout.strip()
|
|
1399
|
+
except Exception:
|
|
1400
|
+
pass
|
|
1401
|
+
|
|
1402
|
+
# Galaxy name
|
|
1403
|
+
galaxy_name = repo_root.name
|
|
1404
|
+
|
|
1405
|
+
if args.format == "json":
|
|
1406
|
+
output = {
|
|
1407
|
+
"galaxy": galaxy_name,
|
|
1408
|
+
"date": TODAY.isoformat(),
|
|
1409
|
+
"graph_health": health_data,
|
|
1410
|
+
"inbox_count": len(inbox),
|
|
1411
|
+
"active_threads": thread_data,
|
|
1412
|
+
"roadmap_summary": roadmap_summary,
|
|
1413
|
+
"issues_summary": issues_summary,
|
|
1414
|
+
"last_commits": last_commit,
|
|
1415
|
+
}
|
|
1416
|
+
print(json.dumps(output, indent=2, default=str))
|
|
1417
|
+
else:
|
|
1418
|
+
print(f"\n{BOLD}Peek: {galaxy_name}{NC}\n")
|
|
1419
|
+
if health_data:
|
|
1420
|
+
score = health_data.get("overall", 0)
|
|
1421
|
+
verdict = health_data.get("verdict", "?")
|
|
1422
|
+
color = GREEN if score >= 80 else (YELLOW if score >= 60 else RED)
|
|
1423
|
+
print(f" {BOLD}Health:{NC} {color}{verdict} ({score}/100){NC} {len(threads)} threads")
|
|
1424
|
+
print(f" {BOLD}Inbox:{NC} {len(inbox)} item(s)")
|
|
1425
|
+
print()
|
|
1426
|
+
if thread_data:
|
|
1427
|
+
print(f" {BOLD}Active Threads{NC} ({len(thread_data)})")
|
|
1428
|
+
for t in thread_data[:8]:
|
|
1429
|
+
stale = t["staleness_days"]
|
|
1430
|
+
stale_color = GREEN if stale <= 3 else (YELLOW if stale <= 7 else RED)
|
|
1431
|
+
print(f" {t['title']} {DIM}hits:{t['hits']} {stale_color}{t['last_touched']}{NC}")
|
|
1432
|
+
print()
|
|
1433
|
+
if roadmap_summary:
|
|
1434
|
+
print(f" {BOLD}Roadmap (excerpt){NC}")
|
|
1435
|
+
for line in roadmap_summary.splitlines()[:10]:
|
|
1436
|
+
print(f" {DIM}{line}{NC}")
|
|
1437
|
+
print()
|
|
1438
|
+
if issues_summary:
|
|
1439
|
+
print(f" {BOLD}Issues (excerpt){NC}")
|
|
1440
|
+
for line in issues_summary.splitlines()[:8]:
|
|
1441
|
+
print(f" {DIM}{line}{NC}")
|
|
1442
|
+
print()
|
|
1443
|
+
if last_commit:
|
|
1444
|
+
print(f" {BOLD}Last Commits{NC}")
|
|
1445
|
+
for line in last_commit.splitlines():
|
|
1446
|
+
print(f" {DIM}{line}{NC}")
|
|
1447
|
+
print()
|
|
1448
|
+
return 0
|
|
1449
|
+
|
|
1450
|
+
commands = {
|
|
1451
|
+
"status": cmd_status,
|
|
1452
|
+
"search": cmd_search,
|
|
1453
|
+
"read": cmd_read,
|
|
1454
|
+
"hit": cmd_hit,
|
|
1455
|
+
"inbox": cmd_inbox,
|
|
1456
|
+
"connect": cmd_connect,
|
|
1457
|
+
"spawn": cmd_spawn,
|
|
1458
|
+
"health": cmd_health,
|
|
1459
|
+
"crawl": cmd_crawl,
|
|
1460
|
+
"suggest": cmd_suggest,
|
|
1461
|
+
"draft": cmd_draft,
|
|
1462
|
+
"thread": cmd_thread,
|
|
1463
|
+
"init": cmd_init,
|
|
1464
|
+
"explain": cmd_explain,
|
|
1465
|
+
"peek": cmd_peek,
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
return commands[args.command](args)
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
if __name__ == "__main__":
|
|
1472
|
+
raise SystemExit(main())
|