#!/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/. 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