Files
echo-v.05/echo-memory.plugin.src/skills/echo-memory/scripts/vault-lint.sh
T
2026-06-11 10:57:01 -05:00

176 lines
6.4 KiB
Bash

#!/usr/bin/env bash
# vault-lint.sh — mechanically assert ECHO vault invariants.
#
# Catches the recurring "invariant violation" bugs that prose rules can't enforce
# on their own: folder<->status drift, duplicate slugs, wikilinks leaking into
# frontmatter, duplicate "## Agent Log" headings, stale active projects, and
# aging inbox captures. Invoked by the monthly Vault Health pass (see SKILL.md),
# but safe to run any time — it is READ-ONLY and never modifies the vault.
#
# Exit status: 0 = clean, 1 = violations found, 2 = vault unreachable.
#
# Config is hardcoded to match the rest of the plugin; override via env if needed:
# ECHO_BASE (default https://echoapi.alwisp.com)
# ECHO_KEY (default the plugin's bearer token)
# STALE_DAYS (default 30) INBOX_DAYS (default 14)
set -euo pipefail
ECHO_BASE="${ECHO_BASE:-https://echoapi.alwisp.com}"
ECHO_KEY="${ECHO_KEY:-241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab}"
STALE_DAYS="${STALE_DAYS:-30}"
INBOX_DAYS="${INBOX_DAYS:-14}"
ECHO_BASE="$ECHO_BASE" ECHO_KEY="$ECHO_KEY" STALE_DAYS="$STALE_DAYS" INBOX_DAYS="$INBOX_DAYS" \
python3 - <<'PY'
import os, sys, json, re, datetime, urllib.request, urllib.error
BASE = os.environ["ECHO_BASE"].rstrip("/")
KEY = os.environ["ECHO_KEY"]
STALE_DAYS = int(os.environ["STALE_DAYS"])
INBOX_DAYS = int(os.environ["INBOX_DAYS"])
TODAY = datetime.date.today()
LIFECYCLES = ["active", "incubating", "on-hold", "archived"]
SKIP = {"README.md", "project-template.md", "decision-template.md"}
violations = []
def flag(check, msg): violations.append((check, msg))
def get(path):
"""GET /vault/<path>. Returns text, or None on 404. Raises on hard failure."""
req = urllib.request.Request(f"{BASE}/vault/{path}",
headers={"Authorization": f"Bearer {KEY}"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def listdir(path):
body = get(path if path.endswith("/") else path + "/")
if body is None:
return []
try:
return json.loads(body).get("files", [])
except json.JSONDecodeError:
return []
def frontmatter(text):
"""Return (raw_frontmatter_str, dict_of_scalar_fields). Empty if no block."""
if not text or not text.startswith("---"):
return "", {}
end = text.find("\n---", 3)
if end == -1:
return "", {}
raw = text[3:end]
fields = {}
for line in raw.splitlines():
m = re.match(r"^([A-Za-z_]+):\s*(.*)$", line)
if m:
fields[m.group(1)] = m.group(2).strip()
return raw, fields
def parse_date(s):
m = re.match(r"(\d{4}-\d{2}-\d{2})", s or "")
if not m:
return None
try:
return datetime.date.fromisoformat(m.group(1))
except ValueError:
return None
# Reachability probe
try:
if get("_agent/echo-vault.md") is None:
print("vault-lint: marker missing — vault may not be bootstrapped.", file=sys.stderr)
except Exception as e:
print(f"vault-lint: vault unreachable ({e}).", file=sys.stderr)
sys.exit(2)
# ---- Projects: folder<->status, stale active, wikilinks-in-frontmatter, dup slugs
slug_homes = {}
for lc in LIFECYCLES:
for fn in listdir(f"projects/{lc}"):
if fn.endswith("/") or fn in SKIP:
continue
slug = fn[:-3] if fn.endswith(".md") else fn
slug_homes.setdefault(slug, []).append(lc)
text = get(f"projects/{lc}/{fn}")
if text is None:
continue
raw, fm = frontmatter(text)
status = fm.get("status", "").strip().strip('"').strip("'")
if status and status != lc:
flag("folder/status", f"projects/{lc}/{fn}: status='{status}' but folder='{lc}'")
if "[[" in raw:
flag("frontmatter-wikilink", f"projects/{lc}/{fn}: '[[...]]' inside frontmatter")
if lc == "active":
d = parse_date(fm.get("updated", ""))
if d and (TODAY - d).days > STALE_DAYS:
flag("stale-active", f"projects/active/{fn}: updated {d} ({(TODAY-d).days}d ago) — consider on-hold/")
for slug, homes in slug_homes.items():
if len(homes) > 1:
flag("duplicate-slug", f"'{slug}' exists in {', '.join(homes)}")
# ---- Wikilinks-in-frontmatter for other high-churn notes
extra = ["_agent/context/current-context.md",
"_agent/memory/semantic/operator-preferences.md"]
for fn in listdir("resources/people"):
if fn.endswith(".md") and fn not in SKIP:
extra.append(f"resources/people/{fn}")
for fn in listdir("_agent/memory/semantic"):
if fn.endswith(".md") and fn not in SKIP:
extra.append(f"_agent/memory/semantic/{fn}")
for path in extra:
text = get(path)
if text is None:
continue
raw, _ = frontmatter(text)
if "[[" in raw:
flag("frontmatter-wikilink", f"{path}: '[[...]]' inside frontmatter")
# ---- Daily notes: duplicate "## Agent Log" headings
for fn in listdir("journal/daily"):
if not fn.endswith(".md") or fn in SKIP:
continue
text = get(f"journal/daily/{fn}") or ""
n = len(re.findall(r"(?m)^## Agent Log\s*$", text))
if n > 1:
flag("duplicate-agent-log", f"journal/daily/{fn}: {n} '## Agent Log' headings")
# ---- Inbox: captures aging past INBOX_DAYS
inbox = get("inbox/captures/inbox.md") or ""
for line in inbox.splitlines():
m = re.match(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\b", line)
if m:
d = parse_date(m.group(1))
if d and (TODAY - d).days > INBOX_DAYS:
flag("aging-inbox", f"inbox capture {d} ({(TODAY-d).days}d): {line.strip()[:80]}")
# ---- Report
if not violations:
print("vault-lint: clean — all invariants hold.")
sys.exit(0)
print(f"vault-lint: {len(violations)} violation(s) found\n")
by = {}
for check, msg in violations:
by.setdefault(check, []).append(msg)
labels = {
"folder/status": "Folder <-> status mismatch",
"duplicate-slug": "Duplicate slug across lifecycle folders",
"frontmatter-wikilink": "Wikilink in frontmatter (breaks reading view)",
"duplicate-agent-log": "Duplicate '## Agent Log' heading",
"stale-active": f"Stale active project (updated > {STALE_DAYS}d)",
"aging-inbox": f"Inbox capture aging (> {INBOX_DAYS}d)",
}
for check, msgs in by.items():
print(f"## {labels.get(check, check)}")
for m in msgs:
print(f" - {m}")
print()
sys.exit(1)
PY