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,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
|