diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d7f7f..a97735a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,13 @@ Format inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -_Nothing yet โ€” open an issue to suggest._ +### Added +- **Weekly lint** (`engine/nightly/gbrain-lint.sh`, launchd Monday 08:00) โ€” verify-and-surface pass over the whole pipeline: doctor, vault lint, orphans, anomalies, back-links, stats, and a "did the nightly actually run in the last 48h" check. ๐ŸŸข/๐ŸŸ /๐Ÿ”ด verdict pinned in `Profile/memory.md` (idempotent marker block), full report in `Profile/lint.md`. Pure CLI, no LLM โ€” a silently-failing maintenance job no longer looks healthy. +- **Link graph actually builds now** โ€” `link_resolution.global_basename` is enabled at install (and idempotently by the nightly for existing installs). Without it, every skeleton dir (`Team/`, `Agents/`, `Decisions/`, `Skills/`, `Journal/`โ€ฆ) is outside gbrain's entity-dir whitelist and all wikilinks were silently dropped: empty graph, forever. Field-tested on a 562-page vault: 0 โ†’ 185 edges. Skeleton `CLAUDE.md`s document the convention: bare-basename wikilinks (`[[Page-Name]]`, no path, no `.md`) + a short `## See also` per page. + +### Changed +- **Nightly**: vault is pushed to its git remote after the nightly commit (best-effort, never blocks โ€” local commits are worth little if the disk dies). Sync and embed are now split (`sync --no-embed` + `embed --stale`): sync's inline embed path fails against `zembed-1` with a misleading parse error and silently stops ingesting the vault. +- **Reflection**: the headless `claude -p` run is retried (3 attempts, 60s apart) โ€” transient API failures ("Connection closed mid-response") were silently losing whole days of journal. Failures are logged to `daily-reflection-errors.log`. ## [0.1.0] โ€” 2026-05-26 โ€” Initial public release diff --git a/README.md b/README.md index 275ddbe..d706574 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Everyone gets the **same engine**, with **their own data** โ€” everything stays | **GBrain** (`~/.gbrain`) | Semantic search over the vault (`gbq query "..."`) โ€” ZeroEntropy embeddings | | **Nightly** (launchd 04:00) | commit vault โ†’ sync โ†’ dream cycle (dedup, facts, consolidation) โ†’ self-update | | **Reflection** (launchd 12:00 + 23:00) | LLM summary of the day's sessions โ†’ `Journal/` + rolling 15-day `memory.md` | +| **Weekly lint** (launchd Monday 08:00) | Verifies the whole pipeline (doctor, lint, orphans, nightly freshness) โ†’ ๐ŸŸข/๐ŸŸ /๐Ÿ”ด verdict pinned in `memory.md` + report in `Profile/lint.md` | It all runs inside Claude Code (terminal/IDE). No bot, no server, no shared cloud. diff --git a/company-skeleton/CLAUDE.md b/company-skeleton/CLAUDE.md index 575fa7a..a6cff4e 100644 --- a/company-skeleton/CLAUDE.md +++ b/company-skeleton/CLAUDE.md @@ -25,5 +25,6 @@ Each member's nightly `git pull`s this vault + re-indexes. The dream cycle dedup ## Rules - **Zero secrets** (shared vault + on GitHub). Locations only. +- Link related pages with bare-basename wikilinks (`[[Page-Name]]`, no path, no `.md`) โ€” that's what builds the shared graph. End substantial pages with a short `## See also`. - 1 decision = 1 dated file in `Decisions/` (`YYYY-MM-DD-topic.md`), clear title, the "why". - Factual, no personal drafts (those go in your personal vault `~/Documents/Brain`). diff --git a/engine/hooks/daily-reflection.py b/engine/hooks/daily-reflection.py index 3df269e..1ccc1e5 100755 --- a/engine/hooks/daily-reflection.py +++ b/engine/hooks/daily-reflection.py @@ -54,20 +54,31 @@ {big} """ -try: - home_claude = Path.home() / ".local" / "bin" / "claude" - claude_bin = ( - os.environ.get("CLAUDE_BIN") - or (str(home_claude) if home_claude.exists() else None) - or shutil.which("claude") - or str(home_claude) - ) - subprocess.run( - [claude_bin, "-p", "--permission-mode", "acceptEdits", prompt], - cwd=str(BRAIN), - timeout=600, - check=False, - ) -except Exception as e: - (LOGS / "daily-reflection-errors.log").open("a").write(f"{time.strftime(chr(37)+chr(70)+chr(84)+chr(37)+chr(84))} {e}" + chr(10)) +home_claude = Path.home() / ".local" / "bin" / "claude" +claude_bin = ( + os.environ.get("CLAUDE_BIN") + or (str(home_claude) if home_claude.exists() else None) + or shutil.which("claude") + or str(home_claude) +) +# Transient API failures ("Connection closed mid-response") kill headless +# claude runs often enough that an unretried cron silently loses whole days +# of journal. 3 attempts, 60s apart; a non-zero exit counts as a failure. +for attempt in range(3): + try: + r = subprocess.run( + [claude_bin, "-p", "--permission-mode", "acceptEdits", prompt], + cwd=str(BRAIN), + timeout=600, + check=False, + ) + if r.returncode == 0: + break + err = f"exit {r.returncode}" + except Exception as e: + err = f"{type(e).__name__}: {e}" + (LOGS / "daily-reflection-errors.log").open("a").write( + f"{time.strftime(chr(37)+chr(70)+chr(84)+chr(37)+chr(84))} attempt {attempt + 1}/3 failed: {err}" + chr(10)) + if attempt < 2: + time.sleep(60) sys.exit(0) diff --git a/engine/nightly/com.USER.gbrain-lint.plist.template b/engine/nightly/com.USER.gbrain-lint.plist.template new file mode 100644 index 0000000..62b1ede --- /dev/null +++ b/engine/nightly/com.USER.gbrain-lint.plist.template @@ -0,0 +1,28 @@ + + + + + Label + com.__USER__.gbrain-lint + ProgramArguments + + /bin/zsh + __HOME__/.claude/hooks/brain/gbrain-lint.sh + + StartCalendarInterval + + Weekday + 1 + Hour + 8 + Minute + 0 + + RunAtLoad + + StandardOutPath + __HOME__/.gbrain/lint.launchd.out + StandardErrorPath + __HOME__/.gbrain/lint.launchd.err + + diff --git a/engine/nightly/gbrain-lint.sh b/engine/nightly/gbrain-lint.sh new file mode 100644 index 0000000..5deed92 --- /dev/null +++ b/engine/nightly/gbrain-lint.sh @@ -0,0 +1,178 @@ +#!/bin/zsh +# brain-in-a-box weekly lint (launchd: com..gbrain-lint, Monday 08:00). +# +# The nightly MAINTAINS the brain (sync/dream) but writes to a log nobody +# reads โ€” a silently-failing maintenance job looks exactly like a healthy one. +# This job VERIFIES and surfaces the result where it gets read: +# - /Profile/lint.md : full report (replaced every run) +# - /Profile/memory.md : one-line verdict between markers +# No LLM pass โ€” pure CLI, immune to transient API failures. Read-only on the +# vault except those two files. + +export PATH="$HOME/.bun/bin:/opt/homebrew/bin:/usr/bin:/bin" +GBRAIN="$HOME/.bun/bin/gbrain" +VAULT="$HOME/Documents/Brain" +LOG="$HOME/.gbrain/lint.log" +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT + +# doctor is cwd-sensitive (its resolver check targets the vault's skills) +cd "$VAULT" || exit 1 + +echo "===== $(date '+%F %T') lint start =====" >> "$LOG" + +# last week's report quotes raw issues โ†’ lint would recount them; remove it +# before the pass (regenerated at the end of the run) +rm -f "$VAULT/Profile/lint.md" + +"$GBRAIN" doctor --fast --json > "$TMP/doctor.json" 2>>"$LOG" +"$GBRAIN" lint "$VAULT" > "$TMP/lint.txt" 2>&1 +"$GBRAIN" orphans --count > "$TMP/orphans.txt" 2>>"$LOG" +"$GBRAIN" anomalies --since 7d > "$TMP/anomalies.txt" 2>&1 +"$GBRAIN" check-backlinks check "$VAULT" > "$TMP/backlinks.txt" 2>&1 +"$GBRAIN" stats > "$TMP/stats.txt" 2>&1 +grep -E 'nightly (start|done)' "$HOME/.gbrain/nightly.log" 2>/dev/null | tail -6 > "$TMP/nightly.txt" + +python3 - "$TMP" "$VAULT" <<'PY' >> "$LOG" 2>&1 +import json, re, sys +from datetime import datetime +from pathlib import Path + +tmp, vault = Path(sys.argv[1]), Path(sys.argv[2]) +today = datetime.now().strftime("%Y-%m-%d") + +def read(name): + p = tmp / name + return p.read_text(errors="replace") if p.exists() else "" + +# --- doctor --- +doctor_status, doctor_score, doctor_fails = "unknown", "?", [] +try: + d = json.loads(read("doctor.json")) + doctor_status = d.get("status", "unknown") + doctor_score = d.get("health_score", "?") + doctor_fails = [c["name"] for c in d.get("checks", []) if c.get("status") == "fail"] +except Exception: + pass + +# --- lint --- +lint_txt = read("lint.txt") +m = re.search(r"(\d+) pages scanned\. (\d+) issue\(s\)", lint_txt) +lint_pages, lint_issues = (int(m.group(1)), int(m.group(2))) if m else (0, -1) + +# --- orphans --- +m = re.search(r"^(\d+)$", read("orphans.txt"), re.M) +orphans = int(m.group(1)) if m else -1 + +# --- anomalies / backlinks --- +anom_txt = read("anomalies.txt").strip() +anomalies_clean = "(no anomalies" in anom_txt or not anom_txt +back_txt = read("backlinks.txt") +backlinks_clean = "No missing back-links" in back_txt + +# --- nightly freshness --- +nightly_txt = read("nightly.txt") +last_done, nightly_age_h = None, None +for line in nightly_txt.splitlines(): + m = re.search(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) nightly done", line) + if m: + last_done = m.group(1) +if last_done: + nightly_age_h = (datetime.now() - datetime.strptime(last_done, "%Y-%m-%d %H:%M:%S")).total_seconds() / 3600 + +# --- stats --- +stats_txt = read("stats.txt").strip() +m = re.search(r"Links:\s+(\d+)", stats_txt) +links = int(m.group(1)) if m else -1 +m = re.search(r"Pages:\s+(\d+)", stats_txt) +db_pages = int(m.group(1)) if m else -1 + +# --- verdict --- +red = (nightly_age_h is None or nightly_age_h > 48) or (isinstance(doctor_score, int) and doctor_score < 50) or lint_issues < 0 +orange = doctor_status != "healthy" or lint_issues > 0 or not backlinks_clean or not anomalies_clean +verdict = "๐Ÿ”ด" if red else ("๐ŸŸ " if orange else "๐ŸŸข") + +reasons = [] +if nightly_age_h is None: + reasons.append("nightly: no 'done' entry found in the log") +elif nightly_age_h > 48: + reasons.append(f"nightly DEAD for {nightly_age_h:.0f}h") +else: + reasons.append(f"nightly OK (last run {last_done})") +reasons.append(f"doctor {doctor_status} ({doctor_score}/100" + (f", fail: {', '.join(doctor_fails)}" if doctor_fails else "") + ")") +if lint_issues >= 0: + reasons.append(f"{lint_issues} frontmatter/artifact issue(s) across {lint_pages} pages") +reasons.append(f"{orphans} orphan pages / {db_pages} in DB, {links} links") +if not anomalies_clean: + reasons.append("statistical anomalies detected (see report)") +if not backlinks_clean: + reasons.append("missing back-links (see report)") + +summary = " ยท ".join(reasons) + +# --- full report -> Profile/lint.md --- +report = f"""--- +title: Weekly brain lint +created: {today} +type: report +--- + +# Weekly lint โ€” {today} {verdict} + +{summary} + +Generated by engine/nightly/gbrain-lint.sh (launchd, Monday 08:00). +Raw output of each pass below. + +## doctor --fast +```json +{read("doctor.json").strip()} +``` + +## lint (frontmatter, LLM artifacts, placeholder dates) +Bulk-fixable: `gbrain lint --fix` +``` +{lint_txt.strip()[-4000:]} +``` + +## anomalies (7 days) +``` +{anom_txt or "(empty)"} +``` + +## back-links +``` +{back_txt.strip()[-1500:]} +``` + +## stats +``` +{stats_txt} +``` + +## nightly (recent runs) +``` +{nightly_txt.strip() or "(nightly log not found)"} +``` +""" +(vault / "Profile" / "lint.md").write_text(report) + +# --- verdict block -> Profile/memory.md (between markers, idempotent) --- +mem_path = vault / "Profile" / "memory.md" +block = ( + "\n" + f"> **๐Ÿงน Weekly lint {today} {verdict}** โ€” {summary}. Detail: `Profile/lint.md`.\n" + "" +) +mem = mem_path.read_text() if mem_path.exists() else "# Memory\n" +if "" in mem: + mem = re.sub(r".*?", block, mem, flags=re.S) +else: + lines = mem.splitlines(keepends=True) + lines.insert(min(2, len(lines)), "\n" + block + "\n") + mem = "".join(lines) +mem_path.write_text(mem) + +print(f"lint {verdict} โ€” {summary}") +PY + +echo "===== $(date '+%F %T') lint done =====" >> "$LOG" diff --git a/engine/nightly/gbrain-nightly.sh b/engine/nightly/gbrain-nightly.sh index cbab3e4..c1a59b0 100755 --- a/engine/nightly/gbrain-nightly.sh +++ b/engine/nightly/gbrain-nightly.sh @@ -63,13 +63,34 @@ if [ -d "$GSTACK/.git" ] && cd "$GSTACK" 2>/dev/null; then fi fi +# 0ter. Link-graph resolution: idempotent, upgrades installs that predate the +# flag (see install.sh โ€” without it every skeleton dir is outside gbrain's +# entity-dir whitelist and wikilinks are silently dropped: empty graph). +"$GBRAIN" config set link_resolution.global_basename true >> "$LOG" 2>&1 + # 1. Commit the personal vault first (sync is git-diff based โ†’ without a commit, edits are invisible). if cd "$VAULT" 2>/dev/null && [ -n "$(git status --porcelain 2>/dev/null)" ]; then git add -A >> "$LOG" 2>&1 git -c user.email="brain@local" -c user.name="brain" commit -q -m "nightly $(date '+%Y-%m-%d')" >> "$LOG" 2>&1 \ && echo "[git] personal vault committed" >> "$LOG" fi -"$GBRAIN" sync --repo "$VAULT" --no-pull >> "$LOG" 2>&1 +# 1bis. Back up the vault off-machine. The vault is the only copy otherwise +# (local commits aren't worth much if the disk dies). Best-effort: a remote may +# be absent and a cron may lack creds โ€” never block the cycle. +# GIT_TERMINAL_PROMPT=0 so a missing credential fails fast instead of hanging. +if cd "$VAULT" 2>/dev/null && git remote get-url origin >/dev/null 2>&1; then + GIT_TERMINAL_PROMPT=0 git push origin HEAD >> "$LOG" 2>&1 \ + && echo "[git] vault pushed to origin" >> "$LOG" \ + || echo "[git] vault push skipped (no remote creds in cron?)" >> "$LOG" +fi +# Import and embed are split on purpose (2026-07-16). `sync` with its built-in +# embed fails on most files with "[embed(zeroentropyai:zembed-1)] Invalid JSON +# response", reported as "N file(s) failed to parse" โ€” a lie: the parse is fine. +# Only sync's inline embed path fails; the same texts embed cleanly on their +# own. Left unsplit, the RAG silently stops ingesting the vault for weeks. +# Re-test `sync` alone after a gbrain upgrade; drop this split once fixed. +"$GBRAIN" sync --repo "$VAULT" --no-pull --no-embed >> "$LOG" 2>&1 +"$GBRAIN" embed --stale >> "$LOG" 2>&1 # 2. COMPANY vault (team mode): pull teammates' contributions + sync the 'company' source. if [ -d "$VAULT_CO/.git" ]; then diff --git a/install.sh b/install.sh index f2ab4ff..232dea2 100755 --- a/install.sh +++ b/install.sh @@ -84,9 +84,10 @@ say "gbq + nightly" mkdir -p "$BIN" sed "s#__HOME__#$H#g" "$REPO/engine/bin/gbq" > "$BIN/gbq" && chmod +x "$BIN/gbq"; ok "gbq โ†’ $BIN/gbq" sed "s#__HOME__#$H#g" "$REPO/engine/nightly/gbrain-nightly.sh" > "$HOOKS/gbrain-nightly.sh" && chmod +x "$HOOKS/gbrain-nightly.sh"; ok "gbrain-nightly.sh" +sed "s#__HOME__#$H#g" "$REPO/engine/nightly/gbrain-lint.sh" > "$HOOKS/gbrain-lint.sh" && chmod +x "$HOOKS/gbrain-lint.sh"; ok "gbrain-lint.sh" -# โ”€โ”€ 7. launchd jobs (nightly maintenance + daily reflection) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -say "launchd (nightly 04:00 + reflection 12:00/23:00)" +# โ”€โ”€ 7. launchd jobs (nightly maintenance + daily reflection + weekly lint) โ”€โ”€โ”€ +say "launchd (nightly 04:00 + reflection 12:00/23:00 + lint Monday 08:00)" mkdir -p "$H/Library/LaunchAgents" load_agent() { #