gator-command 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. gator_command/__init__.py +2 -0
  2. gator_command/cli.py +137 -0
  3. gator_command/scripts/crawler.py +633 -0
  4. gator_command/scripts/dashboard/dashboard.css +982 -0
  5. gator_command/scripts/dashboard/dashboard.html +84 -0
  6. gator_command/scripts/dashboard/dashboard.js +419 -0
  7. gator_command/scripts/dashboard/views/audit.js +270 -0
  8. gator_command/scripts/dashboard/views/fleet.js +307 -0
  9. gator_command/scripts/dashboard/views/repo.js +599 -0
  10. gator_command/scripts/dashboard/views/settings.js +173 -0
  11. gator_command/scripts/dashboard/views/updates.js +308 -0
  12. gator_command/scripts/enforcer-prompt.md +22 -0
  13. gator_command/scripts/extract-claude-sessions.py +489 -0
  14. gator_command/scripts/extract-codex-sessions.py +477 -0
  15. gator_command/scripts/extract-gemini-sessions.py +410 -0
  16. gator_command/scripts/gator-audit.py +956 -0
  17. gator_command/scripts/gator-charter-draft.py +919 -0
  18. gator_command/scripts/gator-charter-lint.py +427 -0
  19. gator_command/scripts/gator-charter-verify.py +606 -0
  20. gator_command/scripts/gator-dashboard.py +1271 -0
  21. gator_command/scripts/gator-deploy.py +916 -0
  22. gator_command/scripts/gator-drift.py +569 -0
  23. gator_command/scripts/gator-enforce.py +82 -0
  24. gator_command/scripts/gator-fleet-intel.py +460 -0
  25. gator_command/scripts/gator-fleet-report.py +615 -0
  26. gator_command/scripts/gator-init-command-post.py +315 -0
  27. gator_command/scripts/gator-init.py +434 -0
  28. gator_command/scripts/gator-machine-id.py +153 -0
  29. gator_command/scripts/gator-policy-status.py +631 -0
  30. gator_command/scripts/gator-pulse.py +459 -0
  31. gator_command/scripts/gator-repo-status.py +649 -0
  32. gator_command/scripts/gator-session-common.py +372 -0
  33. gator_command/scripts/gator-session-sink.py +831 -0
  34. gator_command/scripts/gator-sessions.py +1244 -0
  35. gator_command/scripts/gator-update.py +615 -0
  36. gator_command/scripts/gator-version.py +38 -0
  37. gator_command/scripts/gator_core.py +489 -0
  38. gator_command/scripts/gator_remote.py +381 -0
  39. gator_command/scripts/gator_runtime.py +142 -0
  40. gator_command/scripts/gatorize-actions.sh +989 -0
  41. gator_command/scripts/gatorize-lib.sh +166 -0
  42. gator_command/scripts/gatorize-post.sh +394 -0
  43. gator_command/scripts/gatorize.py +1163 -0
  44. gator_command/scripts/gatorize.sh +185 -0
  45. gator_command/scripts/generate_markdown.py +212 -0
  46. gator_command/scripts/generate_wiki.py +424 -0
  47. gator_command/scripts/graph_health.py +780 -0
  48. gator_command/scripts/memex-lint.py +286 -0
  49. gator_command/scripts/memex-lint.sh +205 -0
  50. gator_command/scripts/memex.py +1472 -0
  51. gator_command/scripts/memex_formatters.py +191 -0
  52. gator_command/scripts/memex_state.py +236 -0
  53. gator_command/scripts/spawn.py +650 -0
  54. gator_command/templates/gator-starter/blueprints/README.md +32 -0
  55. gator_command/templates/gator-starter/charterignore +53 -0
  56. gator_command/templates/gator-starter/charters/README.md +178 -0
  57. gator_command/templates/gator-starter/charters/_template.md +31 -0
  58. gator_command/templates/gator-starter/commands/commit.md +33 -0
  59. gator_command/templates/gator-starter/commands/init.md +11 -0
  60. gator_command/templates/gator-starter/commands/update.md +5 -0
  61. gator_command/templates/gator-starter/constitution.md +165 -0
  62. gator_command/templates/gator-starter/field-guides/README.md +25 -0
  63. gator_command/templates/gator-starter/gator-start-up.md +119 -0
  64. gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
  65. gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
  66. gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
  67. gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
  68. gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
  69. gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
  70. gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
  71. gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
  72. gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
  73. gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
  74. gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
  75. gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
  76. gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
  77. gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
  78. gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
  79. gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
  80. gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
  81. gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
  82. gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
  83. gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
  84. gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
  85. gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
  86. gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
  87. gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
  88. gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
  89. gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
  90. gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
  91. gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
  92. gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
  93. gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
  94. gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
  95. gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
  96. gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
  97. gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
  98. gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
  99. gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
  100. gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
  101. gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
  102. gator_command/templates/gator-starter/sessions/.gitignore +7 -0
  103. gator_command/templates/gator-starter/vault/.gitkeep +0 -0
  104. gator_command/templates/gator-starter/whiteboard.md +5 -0
  105. gator_command-1.0.0.dist-info/METADATA +122 -0
  106. gator_command-1.0.0.dist-info/RECORD +110 -0
  107. gator_command-1.0.0.dist-info/WHEEL +5 -0
  108. gator_command-1.0.0.dist-info/entry_points.txt +2 -0
  109. gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
  110. gator_command-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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())