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,191 @@
1
+ """
2
+ memex_formatters.py — Output formatters for the Memex CLI.
3
+
4
+ Extracted from memex.py to reduce monolithic file size. Contains
5
+ PrettyFormatter (terminal ANSI output) and JsonFormatter (structured
6
+ JSON for agent consumption).
7
+
8
+ Usage:
9
+ from memex_formatters import get_formatter
10
+ fmt = get_formatter("json") # or "pretty"
11
+ print(fmt.status(data))
12
+ """
13
+
14
+ import datetime as dt
15
+ import json
16
+
17
+ # ── Terminal colors ──────────────────────────────────────────────────────────
18
+
19
+ RED = "\033[0;31m"
20
+ YELLOW = "\033[0;33m"
21
+ GREEN = "\033[0;32m"
22
+ CYAN = "\033[0;36m"
23
+ BOLD = "\033[1m"
24
+ DIM = "\033[2m"
25
+ NC = "\033[0m"
26
+
27
+ TODAY = dt.date.today()
28
+
29
+
30
+ class PrettyFormatter:
31
+ """Human-readable terminal output."""
32
+
33
+ def status(self, data: dict) -> str:
34
+ lines = []
35
+ role_info = data.get("role", {})
36
+ role_name = data.get("role_name", "architect")
37
+
38
+ # Header
39
+ lines.append(f"{BOLD}Memex Status{NC} {DIM}role: {role_name}{NC}")
40
+ lines.append("")
41
+
42
+ # Graph health (if available)
43
+ health = data.get("graph_health")
44
+ if health:
45
+ verdict = health["verdict"]
46
+ score = health["overall"]
47
+ color = GREEN if verdict == "HEALTHY" else (YELLOW if verdict == "FAIR" else RED)
48
+ lines.append(f" Graph: {color}{verdict}{NC} ({score}/100) "
49
+ f"{DIM}{health['nodes']} threads, {health['edges']} edges{NC}")
50
+
51
+ # Inbox
52
+ inbox = data.get("inbox", [])
53
+ lines.append(f" Inbox: {len(inbox)} item(s)")
54
+
55
+ # Patterns due
56
+ patterns = data.get("patterns_due", [])
57
+ if patterns:
58
+ lines.append(f" {YELLOW}Due:{NC} {len(patterns)} payment(s) upcoming")
59
+ for p in patterns:
60
+ lines.append(f" {p['vendor']} {p['amount']} — {p['due']}")
61
+
62
+ # Audit findings
63
+ findings = data.get("audit_findings", [])
64
+ if findings:
65
+ lines.append(f" Audit: {YELLOW}{len(findings)} open finding(s){NC}")
66
+
67
+ lines.append("")
68
+
69
+ # Active threads
70
+ threads = data.get("active_threads", [])
71
+ if threads:
72
+ lines.append(f"{BOLD}Active Threads{NC} ({len(threads)})")
73
+ for t in threads:
74
+ stale_flag = f" {DIM}({t['staleness_days']}d ago){NC}" if t["staleness_days"] > 7 else ""
75
+ hits_str = f"{DIM}hits:{t['hits']}{NC}"
76
+ lines.append(f" {t['title']} {hits_str}{stale_flag}")
77
+
78
+ # Next Up items (in full mode)
79
+ for item in t.get("next_up", []):
80
+ lines.append(f" {CYAN}→{NC} {item}")
81
+
82
+ lines.append("")
83
+
84
+ # Inbox items (full mode)
85
+ if data.get("show_inbox_items") and inbox:
86
+ lines.append(f"{BOLD}Inbox Items{NC}")
87
+ for i, item in enumerate(inbox, 1):
88
+ # Truncate long items for terminal readability
89
+ display = item if len(item) <= 120 else item[:117] + "..."
90
+ lines.append(f" {i}. {display}")
91
+ lines.append("")
92
+
93
+ return "\n".join(lines)
94
+
95
+ def search_results(self, results: list[dict]) -> str:
96
+ if not results:
97
+ return f"{DIM}No results found.{NC}"
98
+ lines = [f"{BOLD}{len(results)} result(s){NC}", ""]
99
+ for r in results:
100
+ lines.append(f" {GREEN}{r['file']}{NC} {DIM}{r['type']}{NC}")
101
+ for match in r.get("matches", [])[:3]:
102
+ lines.append(f" {match['line_num']}: {match['text'].strip()}")
103
+ lines.append("")
104
+ return "\n".join(lines)
105
+
106
+ def thread_view(self, data: dict) -> str:
107
+ lines = []
108
+ lines.append(f"{BOLD}{data['title']}{NC}")
109
+
110
+ # Build metadata line based on what's available
111
+ meta_parts = [data['tier']]
112
+ if data.get("status"):
113
+ status_colors = {"building": YELLOW, "shipped": GREEN, "designing": CYAN,
114
+ "considering": DIM, "maintaining": GREEN}
115
+ sc = status_colors.get(data["status"], "")
116
+ meta_parts.append(f"status: {sc}{data['status']}{NC}")
117
+ if data.get("hits"):
118
+ meta_parts.append(f"hits: {data['hits']}")
119
+ if data.get("owner"):
120
+ meta_parts.append(f"owner: {data['owner']}")
121
+ meta_parts.append(f"last-touched: {data['last_touched']}")
122
+ if data.get("tags"):
123
+ meta_parts.append(f"tags: {', '.join(data['tags'])}")
124
+ lines.append(f"{DIM}{' | '.join(meta_parts)}{NC}")
125
+ lines.append("")
126
+
127
+ # If showing a specific section, show just that
128
+ if data.get("_section_content") is not None:
129
+ lines.append(f"{BOLD}{data['_section_name']}{NC}")
130
+ for line in data["_section_content"]:
131
+ lines.append(f" {line}")
132
+ lines.append("")
133
+ return "\n".join(lines)
134
+
135
+ # Summary / Overview
136
+ if data.get("summary"):
137
+ label = "Overview" if data["tier"] == "system" else "Summary"
138
+ lines.append(f"{BOLD}{label}{NC}")
139
+ for line in data["summary"]:
140
+ lines.append(f" {line}")
141
+ lines.append("")
142
+
143
+ # Section list (for system docs — shows navigable structure)
144
+ if data["tier"] in ("system", "report") and data.get("sections"):
145
+ lines.append(f"{BOLD}Sections{NC}")
146
+ for s in data["sections"]:
147
+ lines.append(f" {DIM}--section{NC} \"{s}\"")
148
+ lines.append("")
149
+
150
+ if data.get("connections"):
151
+ lines.append(f"{BOLD}Connections{NC}")
152
+ for line in data["connections"]:
153
+ if line.strip():
154
+ lines.append(f" {line.strip()}")
155
+ lines.append("")
156
+ if data.get("next_up"):
157
+ lines.append(f"{BOLD}Next Up{NC}")
158
+ for item in data["next_up"]:
159
+ lines.append(f" {CYAN}→{NC} {item}")
160
+ lines.append("")
161
+ return "\n".join(lines)
162
+
163
+
164
+ class JsonFormatter:
165
+ """Structured JSON output for agent consumption."""
166
+
167
+ def status(self, data: dict) -> str:
168
+ output = {
169
+ "role": data.get("role_name", "pi"),
170
+ "date": TODAY.isoformat(),
171
+ "graph_health": data.get("graph_health"),
172
+ "inbox_count": len(data.get("inbox", [])),
173
+ "inbox_items": data.get("inbox") if data.get("show_inbox_items") else None,
174
+ "patterns_due": data.get("patterns_due", []),
175
+ "audit_findings": data.get("audit_findings", []),
176
+ "active_threads": data.get("active_threads", []),
177
+ }
178
+ return json.dumps(output, indent=2, default=str)
179
+
180
+ def search_results(self, results: list[dict]) -> str:
181
+ return json.dumps({"results": results, "count": len(results)}, indent=2, default=str)
182
+
183
+ def thread_view(self, data: dict) -> str:
184
+ return json.dumps(data, indent=2, default=str)
185
+
186
+
187
+ def get_formatter(fmt: str):
188
+ """Return the appropriate formatter."""
189
+ if fmt == "json":
190
+ return JsonFormatter()
191
+ return PrettyFormatter()
@@ -0,0 +1,236 @@
1
+ """Shared state and parsing helpers for the Memex CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ import datetime as dt
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from generate_wiki import Thread, parse_frontmatter, parse_sections
11
+
12
+
13
+ SCRIPTS_DIR = Path(__file__).resolve().parent
14
+ TODAY = dt.date.today()
15
+
16
+
17
+ def find_repo_root(override: str | None = None) -> Path:
18
+ """Walk up from the script to find the repo root (contains gator-command/)."""
19
+ if override:
20
+ path = Path(override).resolve()
21
+ if (path / "gator-command").is_dir():
22
+ return path
23
+ print(f"Not a Memex repo: {path} (no gator-command/ directory)")
24
+ raise SystemExit(1)
25
+
26
+ candidate = SCRIPTS_DIR.parent.parent.parent
27
+ if (candidate / "gator-command").is_dir():
28
+ return candidate
29
+
30
+ cwd = Path.cwd()
31
+ if (cwd / "gator-command").is_dir():
32
+ return cwd
33
+ return candidate
34
+
35
+
36
+ def load_roles(repo_root: Path) -> dict:
37
+ """Load role definitions from gator-command/roles.yaml."""
38
+ roles_path = repo_root / "gator-command" / "roles.yaml"
39
+ if not roles_path.exists():
40
+ return {}
41
+ try:
42
+ import yaml
43
+
44
+ return yaml.safe_load(roles_path.read_text(encoding="utf-8")).get("roles", {})
45
+ except ImportError:
46
+ return parse_simple_yaml(roles_path)
47
+
48
+
49
+ def parse_simple_yaml(path: Path) -> dict:
50
+ """Bare-minimum YAML parser for roles.yaml when PyYAML is unavailable."""
51
+ text = path.read_text(encoding="utf-8")
52
+ roles: dict = {}
53
+ current_role: str | None = None
54
+
55
+ for line in text.splitlines():
56
+ stripped = line.strip()
57
+ if not stripped or stripped.startswith("#"):
58
+ continue
59
+
60
+ if stripped == "roles:":
61
+ continue
62
+
63
+ if line.startswith(" ") and not line.startswith(" ") and stripped.endswith(":"):
64
+ current_role = stripped[:-1]
65
+ roles[current_role] = {}
66
+ continue
67
+
68
+ if line.startswith(" ") and current_role and ":" in stripped:
69
+ key, value = stripped.split(":", 1)
70
+ key = key.strip()
71
+ value = value.strip()
72
+
73
+ if value.startswith("[") and value.endswith("]"):
74
+ value = [item.strip() for item in value[1:-1].split(",") if item.strip()]
75
+ elif value == "null":
76
+ value = None
77
+
78
+ roles[current_role][key] = value
79
+
80
+ return roles
81
+
82
+
83
+ def get_role(roles: dict, role_name: str | None) -> dict:
84
+ """Get role config, defaulting to architect for humans."""
85
+ role_name = role_name or "architect"
86
+ if role_name not in roles:
87
+ return {
88
+ "description": f"Unknown role: {role_name}",
89
+ "permissions": ["read"],
90
+ "provenance": f"-{role_name}",
91
+ "session-open-emphasis": [],
92
+ }
93
+ return roles[role_name]
94
+
95
+
96
+ def read_file_safe(path: Path) -> str:
97
+ """Read a file, returning empty string if it doesn't exist."""
98
+ if not path.exists():
99
+ return ""
100
+ return path.read_text(encoding="utf-8")
101
+
102
+
103
+ def parse_inbox(memex_dir: Path) -> list[str]:
104
+ """Parse inbox items (lines starting with '- ')."""
105
+ items = []
106
+ for line in read_file_safe(memex_dir / "inbox.md").splitlines():
107
+ stripped = line.strip()
108
+ if stripped.startswith("- ") and not stripped.startswith("- Drop anything"):
109
+ items.append(stripped[2:])
110
+ return items
111
+
112
+
113
+ def parse_patterns(memex_dir: Path) -> list[dict]:
114
+ """Parse monthly bills/patterns and find items due within 2 days."""
115
+ bills_path = memex_dir / "patterns" / "monthly-bills.md"
116
+ if not bills_path.exists():
117
+ return []
118
+
119
+ text = bills_path.read_text(encoding="utf-8")
120
+ upcoming = []
121
+ days_in_month = calendar.monthrange(TODAY.year, TODAY.month)[1]
122
+
123
+ for line in text.splitlines():
124
+ stripped = line.strip()
125
+ if not stripped.startswith("|"):
126
+ continue
127
+ cols = [col.strip() for col in stripped.split("|")]
128
+ if len(cols) < 4:
129
+ continue
130
+
131
+ day_str = cols[1]
132
+ try:
133
+ day = int(re.sub(r"(st|nd|rd|th)", "", day_str))
134
+ except (ValueError, IndexError):
135
+ continue
136
+
137
+ try:
138
+ if day >= TODAY.day:
139
+ due_date = TODAY.replace(day=min(day, days_in_month))
140
+ else:
141
+ next_month = TODAY.month + 1
142
+ next_year = TODAY.year
143
+ if next_month > 12:
144
+ next_month = 1
145
+ next_year += 1
146
+ next_days_in_month = calendar.monthrange(next_year, next_month)[1]
147
+ due_date = dt.date(next_year, next_month, min(day, next_days_in_month))
148
+ except ValueError:
149
+ continue
150
+
151
+ delta = (due_date - TODAY).days
152
+ if 0 <= delta <= 2:
153
+ upcoming.append({
154
+ "day": day,
155
+ "vendor": cols[2] if len(cols) > 2 else "?",
156
+ "amount": cols[3] if len(cols) > 3 else "?",
157
+ "due": "today" if delta == 0 else f"in {delta} day(s)",
158
+ })
159
+
160
+ return upcoming
161
+
162
+
163
+ def parse_audit_tracker(memex_dir: Path) -> list[str]:
164
+ """Parse open findings from audit-tracker.md."""
165
+ findings = []
166
+ in_open = False
167
+ for line in read_file_safe(memex_dir / "audit-tracker.md").splitlines():
168
+ stripped = line.strip()
169
+ if stripped == "## Open":
170
+ in_open = True
171
+ continue
172
+ if stripped.startswith("## "):
173
+ in_open = False
174
+ if in_open and stripped.startswith("- "):
175
+ findings.append(stripped[2:])
176
+ return findings
177
+
178
+
179
+ def get_thread_next_up(thread_path: Path) -> list[str]:
180
+ """Extract Next Up items from a thread file."""
181
+ text = read_file_safe(thread_path)
182
+ _, sections = parse_sections(text.split("---\n", 2)[-1] if text.startswith("---") else text)
183
+ return [
184
+ line.strip()[2:]
185
+ for line in sections.get("Next Up", [])
186
+ if line.strip().startswith("- ")
187
+ ]
188
+
189
+
190
+ def thread_staleness(thread: Thread) -> int:
191
+ """Days since last touched."""
192
+ try:
193
+ last = dt.date.fromisoformat(thread.last_touched)
194
+ return (TODAY - last).days
195
+ except (ValueError, TypeError):
196
+ return 999
197
+
198
+
199
+ def resolve_thread(memex_dir: Path, name: str) -> Path | None:
200
+ """Resolve a thread name to a file path (active-threads/ then threads/)."""
201
+ for subdir in ("active-threads", "threads"):
202
+ dirpath = memex_dir / subdir
203
+ if not dirpath.is_dir():
204
+ continue
205
+ exact = dirpath / f"{name}.md"
206
+ if exact.exists():
207
+ return exact
208
+ for path in dirpath.glob("*.md"):
209
+ if path.name == "_TEMPLATE.md":
210
+ continue
211
+ if name.lower() in path.stem.lower():
212
+ return path
213
+ return None
214
+
215
+
216
+ def list_thread_names(memex_dir: Path) -> list[str]:
217
+ """List all thread stems for error suggestions."""
218
+ names = []
219
+ for subdir in ("active-threads", "threads"):
220
+ dirpath = memex_dir / subdir
221
+ if dirpath.is_dir():
222
+ names.extend(
223
+ path.stem for path in dirpath.glob("*.md") if path.name != "_TEMPLATE.md"
224
+ )
225
+ return sorted(names)
226
+
227
+
228
+ def get_thread_title(path: Path) -> str:
229
+ """Extract the title from a thread file (frontmatter title or H1)."""
230
+ frontmatter, _ = parse_frontmatter(path.read_text(encoding="utf-8"))
231
+ if frontmatter.get("title"):
232
+ return frontmatter["title"]
233
+ for line in path.read_text(encoding="utf-8").splitlines():
234
+ if line.startswith("# ") and not line.startswith("## "):
235
+ return line[2:].strip()
236
+ return path.stem