From 47eed36a20171f87a2e954bdf8fa7da588580cad Mon Sep 17 00:00:00 2001 From: Fernando Montero Date: Thu, 2 Jul 2026 16:37:09 +0200 Subject: [PATCH] feat: deterministic bin/cx-maintain.sh runner for cron/launchd (zero LLM, zero tokens) Mirrors commands/cx-maintain.md steps as pure bash+python heredoc against installed hooks (repo fallback). Own stale-safe lock, --dry-run, idempotent. 9/9 sandbox tests; real-data run confirms no-op after today's maintain. Co-Authored-By: Claude Fable 5 --- bin/cx-maintain.sh | 405 +++++++++++++++++++++++++++++++ commands/cx-maintain.md | 28 ++- docs/FEATURES.md | 2 +- docs/MIGRATION-V4.md | 59 ++++- tests/test_cx_maintain_runner.sh | 153 ++++++++++++ 5 files changed, 636 insertions(+), 11 deletions(-) create mode 100755 bin/cx-maintain.sh create mode 100755 tests/test_cx_maintain_runner.sh diff --git a/bin/cx-maintain.sh b/bin/cx-maintain.sh new file mode 100755 index 0000000..b26f83e --- /dev/null +++ b/bin/cx-maintain.sh @@ -0,0 +1,405 @@ +#!/usr/bin/env bash +# bin/cx-maintain.sh — Deterministic /cx-maintain pass, runnable WITHOUT an LLM. +# +# Same contract as commands/cx-maintain.md's Implementation block (Cortex v4, +# docs/DESIGN-V4.md §5): decay, archive, auto-validate, deterministic +# law-promotion, evolve-detect (engine pass) → Jaccard dedup by +# (scope, project_id, domain) → storage rotation → proposals↔instincts +# reconciliation → health check → .review-digest.json → .last-distill / +# .last-dream markers. Zero questions, zero LLM calls — safe for cron/launchd. +# +# This script MUST stay a byte-for-byte behavioral mirror of the Python +# heredoc in commands/cx-maintain.md. If that spec changes (new step, engine +# function rename, gate change), update both in the same commit — see +# instinct pattern-cortex-commands-md-features-pair. +# +# Usage: +# bin/cx-maintain.sh # full pass, writes state, prints report +# bin/cx-maintain.sh --dry-run # same computation, no writes anywhere +# +# Env overrides (mainly for tests — see tests/test_cx_maintain_runner.sh): +# CORTEX_DIR data dir, default ~/.claude/cortex +# CORTEX_LIB_DIR engine lib dir, default: installed hooks, falls back to +# the repo's own hooks/lib if not installed +# CX_MAINTAIN_LOCK_STALE_SEC seconds before a stale runner lock is stolen +# (default 1800 = 30 min) +# +# macOS BSD `date`/`mktemp` compatible. No GNU-only flags used anywhere. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +CORTEX_DIR="${CORTEX_DIR:-$HOME/.claude/cortex}" + +INSTALLED_LIB_DIR="$HOME/.claude/hooks/cortex/lib" +REPO_LIB_DIR="$REPO_ROOT/hooks/lib" +if [ -n "${CORTEX_LIB_DIR:-}" ]; then + LIB_DIR="$CORTEX_LIB_DIR" +elif [ -f "$INSTALLED_LIB_DIR/distill_engine.py" ]; then + LIB_DIR="$INSTALLED_LIB_DIR" +else + LIB_DIR="$REPO_LIB_DIR" +fi + +DRY_RUN=0 +[ "${1:-}" = "--dry-run" ] && DRY_RUN=1 + +log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } + +log "cx-maintain runner starting (dry_run=$DRY_RUN)" +log "CORTEX_DIR=$CORTEX_DIR" +log "LIB_DIR=$LIB_DIR" + +if [ ! -f "$LIB_DIR/distill_engine.py" ]; then + log "ERROR: distill_engine.py not found under $LIB_DIR (neither installed nor repo copy) — nothing to run" + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + log "ERROR: python3 not found on PATH — cx-maintain requires it" + exit 1 +fi + +mkdir -p "$CORTEX_DIR" + +# ── Runner-level lock ──────────────────────────────────────────────────────── +# Separate from distill_engine's own advisory flock (which only guards the +# engine-pass step). This one guards the WHOLE script against an overlapping +# cron/launchd invocation (e.g. a slow storage-rotation run still going when +# the next scheduled tick fires). Portable mkdir-based lock (atomic on every +# POSIX fs, no flock(1) needed — flock(1) does not ship on macOS). +LOCK_DIR="$CORTEX_DIR/.cx-maintain-runner.lock" +STALE_SEC="${CX_MAINTAIN_LOCK_STALE_SEC:-1800}" + +release_lock() { rmdir "$LOCK_DIR" 2>/dev/null || true; } + +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + # Someone else holds it. Steal if stale (crashed run left it behind). + lock_age=99999 + if [ -d "$LOCK_DIR" ]; then + lock_mtime=$(stat -f %m "$LOCK_DIR" 2>/dev/null || stat -c %Y "$LOCK_DIR" 2>/dev/null || echo 0) + now_epoch=$(date +%s) + lock_age=$(( now_epoch - lock_mtime )) + fi + if [ "$lock_age" -ge "$STALE_SEC" ]; then + log "Stale runner lock (${lock_age}s old, >= ${STALE_SEC}s) — stealing" + rmdir "$LOCK_DIR" 2>/dev/null || true + mkdir "$LOCK_DIR" 2>/dev/null || { log "Could not steal lock — another run just started, skipping"; exit 0; } + else + log "Another cx-maintain runner is active (lock ${lock_age}s old) — skipping this run, safe to rerun later" + exit 0 + fi +fi +trap release_lock EXIT + +set +e +CORTEX_DIR="$CORTEX_DIR" LIB_DIR="$LIB_DIR" DRY_RUN="$DRY_RUN" python3 - <<'PYEOF' +import json, os, re, sys, time +from datetime import date, datetime, timezone +from pathlib import Path + +CORTEX_DIR = Path(os.environ["CORTEX_DIR"]) +LIB_DIR = os.environ["LIB_DIR"] +DRY_RUN = os.environ.get("DRY_RUN") == "1" + +sys.path.insert(0, LIB_DIR) + +report = {"steps": [], "errors": []} + +def step(name): + def deco(fn): + try: + out = fn() + report["steps"].append({"name": name, "ok": True, "detail": out}) + except Exception as e: + report["steps"].append({"name": name, "ok": False, "detail": str(e)}) + report["errors"].append(f"{name}: {e}") + return report["steps"][-1] + return deco + +# ── Step 1: engine pass (decay + purge + auto-validate + promote + evolve) ── +# All under distill_engine's own lock so this never races SessionStart's +# daily auto-distill or session-learner.js's Stop hook. Calls the SAME +# public functions run_auto_distill() calls internally, but bypasses its +# 24h rate limiter — a deliberate /cx-maintain run should always execute, +# not silently no-op because SessionStart already ran today. +decayed = archived = promoted = candidates = [] +validated = skipped_validate = evolve_drafts = 0 +engine_ok = False +try: + import distill_engine as de + lock_fh, acquired = de._lock_acquire(nonblocking=True) + if not acquired: + report["steps"].append({"name": "engine-pass", "ok": False, + "detail": "lock busy (another distill process running) — skipped, safe to rerun"}) + else: + try: + decayed = de.apply_decay(dry_run=DRY_RUN) + archived = de.archive_decayed(dry_run=DRY_RUN) + validate_result = de.auto_validate_proposals(dry_run=DRY_RUN) + validated = len(validate_result.get("accepted", [])) + skipped_validate = len(validate_result.get("skipped", [])) + promoted, candidates = de.auto_promote_to_law(dry_run=DRY_RUN) + evolve_result = de.auto_evolve_detect(dry_run=DRY_RUN) + evolve_drafts = len(evolve_result.get("drafts_generated", [])) + engine_ok = True + report["steps"].append({"name": "engine-pass", "ok": True, "detail": + f"decayed={len(decayed)} archived={len(archived)} validated={validated} " + f"skipped_validate={skipped_validate} promoted={len(promoted)} " + f"candidates={len(candidates)} evolve_drafts={evolve_drafts}"}) + finally: + de._lock_release(lock_fh) +except Exception as e: + report["steps"].append({"name": "engine-pass", "ok": False, "detail": str(e)}) + report["errors"].append(f"engine-pass: {e}") + +# ── Step 2: dedup Jaccard (dream_cycle.dedup_instincts, threshold 0.80) ── +# Groups by (scope, project_id, domain) so global instincts never dedup +# against project-scoped ones and unrelated domains never collide. Archives +# the losers using the SAME convention as archive_decayed (rename into a +# sibling archive/ dir), so they stay recoverable, not deleted. +dedup_archived = [] +try: + import distill_engine as de + import dream_cycle as dc + groups = {} + for path in de._all_instinct_paths(): + result = de._read_instinct(path) + if result is None: + continue + fields, _text = result + key = (fields.get("scope", "?"), fields.get("project_id", "?"), fields.get("domain", "?")) + groups.setdefault(key, []).append({ + "id": fields.get("id", path.stem), + "action": fields.get("action", ""), + "confidence": float(fields.get("confidence", 0) or 0), + "_path": path, + }) + for key, items in groups.items(): + if len(items) < 2: + continue + kept = dc.dedup_instincts(items, threshold=0.80) + kept_ids = {k["id"] for k in kept} + for it in items: + if it["id"] in kept_ids: + continue + if not DRY_RUN: + dest_dir = it["_path"].parent / "archive" + dest_dir.mkdir(parents=True, exist_ok=True) + it["_path"].rename(dest_dir / it["_path"].name) + de._log_knowledge("dedup-archived", it["id"], + f"jaccard>=0.80 duplicate within domain={key[2]}", source="cx-maintain") + dedup_archived.append(it["id"]) + report["steps"].append({"name": "dedup-jaccard", "ok": True, + "detail": f"archived {len(dedup_archived)} duplicate(s): {dedup_archived[:10]}"}) +except Exception as e: + report["steps"].append({"name": "dedup-jaccard", "ok": False, "detail": str(e)}) + report["errors"].append(f"dedup-jaccard: {e}") + +# ── Step 3: storage rotation (storage-rotation.js, size+marker gated) ── +rotation_out = "" +try: + import subprocess + node_snippet = ( + "const r=require(process.env.LIB_DIR+'/storage-rotation.js');" + "const lines=[];" + "r.maybeRotateStorage(l=>lines.push(l));" + "console.log(JSON.stringify(lines));" + ) + env = dict(os.environ) + if not DRY_RUN: + cp = subprocess.run(["node", "-e", node_snippet], env=env, + capture_output=True, text=True, timeout=30) + rotation_out = cp.stdout.strip() or cp.stderr.strip() + else: + rotation_out = "(dry-run: rotation skipped)" + report["steps"].append({"name": "storage-rotation", "ok": True, "detail": rotation_out}) +except Exception as e: + report["steps"].append({"name": "storage-rotation", "ok": False, "detail": str(e)}) + report["errors"].append(f"storage-rotation: {e}") + +# ── Step 4: reconciliation proposals-history ↔ instincts ── +# If the LATEST known status for a proposal id is "rejected" (checked across +# proposals-history.jsonl in append order, then proposals.json for any live +# leftovers) but an ACTIVE instinct YAML with that id still exists, archive +# it. Closes the gap left by the deprecated /cx-downvote (no longer wired to +# archival) and by any manual proposals.json edits. +reconciled = [] +try: + import distill_engine as de + latest_status = {} + hist_file = CORTEX_DIR / "proposals-history.jsonl" + if hist_file.exists(): + with open(hist_file, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + pid = obj.get("id") + if pid: + latest_status[pid] = obj.get("status") + props_file = CORTEX_DIR / "proposals.json" + if props_file.exists(): + try: + arr = json.loads(props_file.read_text(encoding="utf-8")) + if isinstance(arr, list): + for obj in arr: + if isinstance(obj, dict) and obj.get("id"): + latest_status[obj["id"]] = obj.get("status") + except Exception: + pass + + rejected_ids = {pid for pid, st in latest_status.items() if st == "rejected"} + if rejected_ids: + for path in de._all_instinct_paths(): + if path.stem not in rejected_ids: + continue + result = de._read_instinct(path) + if result is None: + continue + fields, _text = result + iid = str(fields.get("id", path.stem)) + if not DRY_RUN: + dest_dir = path.parent / "archive" + dest_dir.mkdir(parents=True, exist_ok=True) + path.rename(dest_dir / path.name) + de._log_knowledge("archived-reconciled", iid, + "latest proposal status=rejected but instinct still active", source="cx-maintain") + reconciled.append(iid) + report["steps"].append({"name": "reconcile-proposals-instincts", "ok": True, + "detail": f"archived {len(reconciled)} instinct(s): {reconciled[:10]}"}) +except Exception as e: + report["steps"].append({"name": "reconcile-proposals-instincts", "ok": False, "detail": str(e)}) + report["errors"].append(f"reconcile: {e}") + +# ── Step 5: health check + counts ── +health = {"laws_active": 0, "laws_cap": 15, "instincts_draft": 0, "instincts_confirmed": 0, + "instincts_archived": 0, "proposals_pending_human": 0, "proposals_pending_auto": 0, + "reflexes_noisy": 0, "law_deprecation_candidate": None} +try: + import distill_engine as de + health["laws_active"] = de._active_law_count() + health["laws_cap"] = de.LAW_MAX_ACTIVE + + for path in de._all_instinct_paths(): + result = de._read_instinct(path) + if result is None: + continue + fields, _text = result + status = str(fields.get("status", "confirmed")).strip().lower() or "confirmed" + if status == "draft": + health["instincts_draft"] += 1 + else: + health["instincts_confirmed"] += 1 + for pattern_dir in [CORTEX_DIR / "instincts" / "global" / "archive"] + \ + list((CORTEX_DIR / "projects").glob("*/instincts/archive")): + if pattern_dir.is_dir(): + health["instincts_archived"] += sum(1 for _ in pattern_dir.glob("*.yaml")) + + props_file = CORTEX_DIR / "proposals.json" + if props_file.exists(): + try: + arr = json.loads(props_file.read_text(encoding="utf-8")) + if isinstance(arr, list): + for p in arr: + if not isinstance(p, dict) or p.get("status", "pending") != "pending": + continue + if p.get("domain") in de.VALIDATE_AUTO_DOMAINS: + health["proposals_pending_auto"] += 1 + else: + health["proposals_pending_human"] += 1 + except Exception: + pass + + try: + reflexes = json.loads((CORTEX_DIR / "reflexes.json").read_text(encoding="utf-8")).get("reflexes", []) + for r in reflexes: + fire, useful, noise = r.get("fireCount", 0), r.get("usefulCount", 0), r.get("noiseCount", 0) + if noise >= 3 and fire >= 10 and useful < noise: + health["reflexes_noisy"] += 1 + except Exception: + pass + + try: + impact = de._impact_per_iid(days=14) + health["law_deprecation_candidate"] = de._find_least_impactful_law(impact) + except Exception: + pass + + report["steps"].append({"name": "health-check", "ok": True, "detail": health}) +except Exception as e: + report["steps"].append({"name": "health-check", "ok": False, "detail": str(e)}) + report["errors"].append(f"health-check: {e}") + +# ── Step 6: write the /cx-review digest (successor to auto-distill-candidates.md) ── +digest_written = False +if not DRY_RUN: + try: + digest = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "proposals_human_gated": health["proposals_pending_human"], + "instincts_draft_total": health["instincts_draft"], + "law_candidates": [{"id": c["id"], "reasons": c.get("reasons", [])[:2]} for c in (candidates or [])[:20]], + "law_deprecation_candidate": health["law_deprecation_candidate"], + "laws_active": health["laws_active"], + "laws_cap": health["laws_cap"], + } + digest["total_items"] = ( + digest["proposals_human_gated"] + + len(digest["law_candidates"]) + + (1 if digest["law_deprecation_candidate"] else 0) + ) + tmp = CORTEX_DIR / ".review-digest.json.tmp" + tmp.write_text(json.dumps(digest, indent=2), encoding="utf-8") + os.replace(tmp, CORTEX_DIR / ".review-digest.json") + digest_written = True + report["steps"].append({"name": "write-digest", "ok": True, + "detail": f"total_items={digest['total_items']}"}) + except Exception as e: + report["steps"].append({"name": "write-digest", "ok": False, "detail": str(e)}) + report["errors"].append(f"write-digest: {e}") +else: + report["steps"].append({"name": "write-digest", "ok": True, "detail": "(dry-run: not written)"}) + +# ── Step 7: compat markers .last-distill / .last-dream ── +if not DRY_RUN: + try: + now_iso = datetime.now(timezone.utc).isoformat() + (CORTEX_DIR / ".last-distill").write_text(now_iso + "\n", encoding="utf-8") + (CORTEX_DIR / ".last-dream").write_text(now_iso + "\n", encoding="utf-8") + report["steps"].append({"name": "compat-markers", "ok": True, "detail": "touched .last-distill, .last-dream"}) + except Exception as e: + report["steps"].append({"name": "compat-markers", "ok": False, "detail": str(e)}) + report["errors"].append(f"compat-markers: {e}") +else: + report["steps"].append({"name": "compat-markers", "ok": True, "detail": "(dry-run: not touched)"}) + +# ── Report ── +print("================================================================") +print(f" CX-MAINTAIN{' (dry-run)' if DRY_RUN else ''} — {datetime.now().strftime('%Y-%m-%d %H:%M')}") +print("================================================================") +for s in report["steps"]: + mark = "OK " if s["ok"] else "ERR" + print(f" [{mark}] {s['name']}: {s['detail']}") +print("----------------------------------------------------------------") +if report["errors"]: + print(f" {len(report['errors'])} step(s) failed — see [ERR] lines above. Other steps still ran.") +else: + print(" All steps completed cleanly.") +print("================================================================") +PYEOF +PY_RC=$? +set -e + +if [ "$PY_RC" -ne 0 ]; then + log "ERROR: python3 engine pass crashed (rc=$PY_RC) — this is an infra failure, not a normal step error" + exit "$PY_RC" +fi + +log "cx-maintain runner finished cleanly" +exit 0 diff --git a/commands/cx-maintain.md b/commands/cx-maintain.md index 5f9c390..30ba79c 100644 --- a/commands/cx-maintain.md +++ b/commands/cx-maintain.md @@ -35,12 +35,31 @@ one-line `[REVIEW] N items pendientes` badge. # including the digest — pure preview) ``` -### Cron / scheduled use +### Cron / launchd — `bin/cx-maintain.sh`, no LLM needed + +**Recommended path for unattended scheduling.** `bin/cx-maintain.sh` is a +plain bash script — no `claude -p`, no model call, no tokens spent — that +mirrors this command's Implementation section byte-for-byte against the +SAME engine functions. It resolves the engine lib dir from the installed +hooks (`~/.claude/hooks/cortex/lib`) with a fallback to the repo's own +`hooks/lib` when running from a checkout, guards against overlapping runs +with its own mkdir-based lock, and exits 0 on a clean pass: + +```bash +bin/cx-maintain.sh # full pass, writes state, prints report +bin/cx-maintain.sh --dry-run # same computation, no writes +``` ```cron -0 4 * * 0 claude -p "/cx-maintain" >> ~/.claude/cortex/log/cx-maintain-cron.log 2>&1 +0 4 * * 0 /path/to/fs-cortex/bin/cx-maintain.sh >> ~/.claude/cortex/log/cx-maintain-cron.log 2>&1 ``` +See `docs/MIGRATION-V4.md` §"Scheduling `/cx-maintain` weekly" for the +launchd plist equivalent. Prefer `bin/cx-maintain.sh` over `claude -p +"/cx-maintain"` for any unattended schedule — same steps, same output +contract, zero token cost. `claude -p "/cx-maintain"` remains a valid +interactive/manual invocation. + Weekly is the suggested cadence (the daily "maintain-lite" — decay + rotation + the fast engine pass — already runs automatically at every SessionStart via `run_auto_distill()`, see `hooks/session-start.py`). Running `/cx-maintain` @@ -54,6 +73,11 @@ tool calls — that reintroduces judgment between steps, which this command must not have. Report exactly what the script prints; do not editorialize, do not ask the user anything, do not add recommendations of your own. +This exact sequence is also packaged as `bin/cx-maintain.sh` — ejecutable sin +LLM vía `bin/cx-maintain.sh`, es la vía recomendada para cron/launchd (see +"Cron / launchd" above). The Python heredoc below and that script MUST stay +in sync; if you change one, change the other in the same commit. + ```bash CORTEX_DIR="${CORTEX_DIR:-$HOME/.claude/cortex}" LIB_DIR="${CORTEX_LIB_DIR:-$HOME/.claude/hooks/cortex/lib}" diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b117beb..234190c 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -252,7 +252,7 @@ mapping: [`MIGRATION-V4.md`](MIGRATION-V4.md). | Command | Purpose | Token Cost | |---|---|---| | `/cx-status` | Dashboard: laws, instincts, projects, reflexes, tracking, health, domain grouping. `--impact`, `--reflexes`, `--pipeline` flags unchanged from v3 | ~200 | -| `/cx-maintain` | **New in v4, deterministic, cron-able.** Single pass: engine decay/purge/auto-validate/deterministic law-promotion, Jaccard dedup by subtopic, storage rotation, proposals↔instincts reconciliation, health check, writes `.review-digest.json` for `/cx-review`. Zero questions, idempotent | ~400 | +| `/cx-maintain` | **New in v4, deterministic, cron-able.** Single pass: engine decay/purge/auto-validate/deterministic law-promotion, Jaccard dedup by subtopic, storage rotation, proposals↔instincts reconciliation, health check, writes `.review-digest.json` for `/cx-review`. Zero questions, idempotent. Also ships as `bin/cx-maintain.sh` — a plain bash mirror of the same steps for cron/launchd, no LLM call, 0 tokens | ~400 / 0 (script) | | `/cx-review` | **New in v4, the only command with judgment, weekly.** Presents the accumulated digest (human-gated proposals + evolve drafts + law deprecation candidate) as ONE shorthand list. Fuses the old `cx-validate` + `cx-evolve` confirmation + `cx-downvote` + `cx-distill --swap` | ~600 | | `/cx-eod` | End-of-day summary. Deterministic gather (`core/_cx-eod-gather.sh`, no LLM); structurally cumulative (rereads the full day on every run); **v4**: per-project `context` field from `context.md`, `[eod-eisenhower]` Q1–Q4 keyword classification of "for tomorrow" bullets on next-day reinjection, review-digest pending count surfaced in `--write` mode | ~300 / 0 (auto) | | `/cx-gotcha` | Capture error→fix as high-priority instinct (unchanged) | ~200 | diff --git a/docs/MIGRATION-V4.md b/docs/MIGRATION-V4.md index a09e877..912918d 100644 --- a/docs/MIGRATION-V4.md +++ b/docs/MIGRATION-V4.md @@ -117,19 +117,62 @@ SessionStart automatically, so the schedule below is a weekly top-up, not a requirement for the system to function — but without it, promotion/dedup progress only advances on days you happen to open Claude Code. -**Cron (macOS/Linux)** — Sunday at 4am: +**Recommended: `bin/cx-maintain.sh`, no LLM, no tokens.** The command's +Implementation section is also shipped as a standalone bash script that +calls the exact same `distill_engine.py` / `dream_cycle.py` / +`storage-rotation.js` functions directly — no `claude -p`, no model call +involved. It resolves the engine lib from the installed hooks +(`~/.claude/hooks/cortex/lib`) with a fallback to the repo's `hooks/lib`, +takes its own mkdir-based lock so overlapping scheduled runs don't race each +other, and always exits 0 on a clean pass (nonzero only on a real infra +failure — missing python3, missing engine lib). This is now the default way +to schedule maintenance; `claude -p "/cx-maintain"` still works for a manual +or interactive run. + +**Cron (macOS/Linux)** — Sunday at 4am, script-based (preferred, zero token cost): ```cron -0 4 * * 0 claude -p "/cx-maintain" >> ~/.claude/cortex/log/cx-maintain-cron.log 2>&1 +0 4 * * 0 /path/to/fs-cortex/bin/cx-maintain.sh >> ~/.claude/cortex/log/cx-maintain-cron.log 2>&1 ``` -**Claude Code schedule** — equivalent, if you prefer not to touch crontab -directly, register the same command through Claude Code's own `schedule` -feature (routine, weekly cadence, `/cx-maintain` as the prompt). +**launchd (macOS)** — equivalent as a LaunchAgent, e.g. +`~/Library/LaunchAgents/com.fscortex.cx-maintain.plist`: + +```xml + + + + + Labelcom.fscortex.cx-maintain + ProgramArguments + + /path/to/fs-cortex/bin/cx-maintain.sh + + StartCalendarInterval + + Weekday0 + Hour4 + Minute0 + + StandardOutPath/tmp/cx-maintain-launchd.log + StandardErrorPath/tmp/cx-maintain-launchd.log + + +``` + +Load it with `launchctl load ~/Library/LaunchAgents/com.fscortex.cx-maintain.plist`. +Redirect `StandardOutPath`/`StandardErrorPath` to a real log location under +`~/.claude/cortex/log/` once created — `/tmp` is only illustrative here. + +**Claude Code schedule** — if you prefer an LLM-driven trigger instead of +cron/launchd, register `/cx-maintain` through Claude Code's own `schedule` +feature (routine, weekly cadence, `/cx-maintain` as the prompt). This costs +tokens on every run, unlike `bin/cx-maintain.sh`. -After `/cx-maintain` runs, check `hooks/session-start.py`'s `[REVIEW] N items -pendientes` badge at your next SessionStart — that's your cue to run -`/cx-review`. +After `/cx-maintain` (or `bin/cx-maintain.sh`) runs, check +`hooks/session-start.py`'s `[REVIEW] N items pendientes` badge at your next +SessionStart — that's your cue to run `/cx-review`. ## See also diff --git a/tests/test_cx_maintain_runner.sh b/tests/test_cx_maintain_runner.sh new file mode 100755 index 0000000..3c77a9f --- /dev/null +++ b/tests/test_cx_maintain_runner.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# tests/test_cx_maintain_runner.sh — bin/cx-maintain.sh (no-LLM /cx-maintain runner). +# Drives the REAL script in a hermetic sandbox (mktemp -d, never touches real +# ~/.claude/cortex). Covers: exit 0 on repeated runs, compat markers touched, +# valid .review-digest.json, and idempotency (a same-day rerun does not +# re-decay an instinct already decayed today — proves the runner honors +# distill_engine's own last_decay_at guard rather than forcing extra work). +# Run: bash tests/test_cx_maintain_runner.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +RUNNER="$PROJECT_ROOT/bin/cx-maintain.sh" + +PASS=0 +FAIL=0 +pass() { PASS=$((PASS + 1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL + 1)); echo " FAIL: $1"; } + +SANDBOX="$(mktemp -d -t cortex-maintain-runner-test-XXXXXX)" +trap 'rm -rf "$SANDBOX"' EXIT + +echo "=== bin/cx-maintain.sh Tests (sandbox: $SANDBOX) ===" +echo + +# ── Sandbox layout: mirrors the real CORTEX_DIR tree, isolated per test run ── +CDIR="$SANDBOX/cortex" +mkdir -p "$CDIR/instincts/global" "$CDIR/laws" "$CDIR/projects" + +# Force the runner to use the REPO's own hooks/lib, never anything installed +# under ~/.claude — this is the whole point of hermetic sandboxing. +export CORTEX_LIB_DIR="$PROJECT_ROOT/hooks/lib" + +# A stale-enough instinct (last_seen 90 days ago) so apply_decay has real +# work to do on the first run, and a fresh one that should never decay. +NINETY_DAYS_AGO=$(python3 -c "import datetime; print((datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=90)).strftime('%Y-%m-%d'))") +TODAY=$(python3 -c "import datetime; print(datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d'))") + +cat > "$CDIR/instincts/global/stale-decay-candidate.yaml" < "$CDIR/instincts/global/fresh-instinct.yaml" <&1) +RC1=$? +[ "$RC1" -eq 0 ] && pass "First run exit 0" || { fail "First run exit $RC1"; echo "$OUT1"; } + +# ── Test 2: report shows the stale instinct decayed exactly once ──────────── +echo "--- Test 2: first run decays the stale instinct ---" +echo "$OUT1" | grep -q "engine-pass: decayed=1" \ + && pass "First run reports decayed=1" \ + || { fail "Expected decayed=1 in first-run report"; echo "$OUT1" | grep "engine-pass"; } + +# ── Test 3: compat markers touched ─────────────────────────────────────────── +echo "--- Test 3: compat markers written ---" +if [ -f "$CDIR/.last-distill" ] && [ -f "$CDIR/.last-dream" ]; then + pass "Both .last-distill and .last-dream exist" +else + fail ".last-distill or .last-dream missing" +fi + +# ── Test 4: digest is valid JSON with expected keys ────────────────────────── +echo "--- Test 4: .review-digest.json is valid JSON ---" +if [ -f "$CDIR/.review-digest.json" ]; then + DIGEST_OK=$(python3 -c " +import json +d = json.load(open('$CDIR/.review-digest.json')) +required = ['generated_at', 'total_items', 'laws_active', 'laws_cap'] +print('ok' if all(k in d for k in required) else 'missing-keys') +" 2>&1) + [ "$DIGEST_OK" = "ok" ] && pass "Digest JSON valid with required keys" || fail "Digest malformed: $DIGEST_OK" +else + fail ".review-digest.json not written" +fi + +# ── Test 5: second (same-day) run exits 0 too ──────────────────────────────── +echo "--- Test 5: second run exits 0 ---" +OUT2=$(CORTEX_DIR="$CDIR" "$RUNNER" 2>&1) +RC2=$? +[ "$RC2" -eq 0 ] && pass "Second run exit 0" || { fail "Second run exit $RC2"; echo "$OUT2"; } + +# ── Test 6: idempotency — second run does NOT re-decay the same instinct ──── +# apply_decay writes last_decay_at=today on the first pass; a same-day rerun +# must see decayed=0 for that instinct. This is the real idempotency proof +# (not just "ran twice without crashing"). +echo "--- Test 6: idempotent — no re-decay on same-day rerun ---" +echo "$OUT2" | grep -q "engine-pass: decayed=0" \ + && pass "Second run reports decayed=0 (idempotent)" \ + || { fail "Expected decayed=0 on second run"; echo "$OUT2" | grep "engine-pass"; } + +# ── Test 7: fresh instinct never touched by decay in either run ───────────── +echo "--- Test 7: fresh instinct confidence unchanged ---" +FRESH_CONF=$(grep '^confidence:' "$CDIR/instincts/global/fresh-instinct.yaml" | head -1) +[ "$FRESH_CONF" = "confidence: 0.75" ] \ + && pass "Fresh instinct confidence untouched (0.75)" \ + || fail "Fresh instinct confidence changed: $FRESH_CONF" + +# ── Test 8: idempotency at the file level — running twice does not duplicate +# marker files, digest, or leave stray lock dirs behind ───────────────────── +echo "--- Test 8: no leftover lock dir after clean exit ---" +if [ -d "$CDIR/.cx-maintain-runner.lock" ]; then + fail "Runner lock dir left behind after clean exit" +else + pass "Runner lock dir released" +fi + +# ── Test 9: --dry-run makes zero writes ────────────────────────────────────── +echo "--- Test 9: --dry-run writes nothing new ---" +CDIR2="$SANDBOX/cortex-dryrun" +mkdir -p "$CDIR2/instincts/global" "$CDIR2/laws" "$CDIR2/projects" +cp "$CDIR/instincts/global/stale-decay-candidate.yaml" "$CDIR2/instincts/global/stale-decay-candidate.yaml" 2>/dev/null || true +# Reset last_decay_at removed copy back to a decayable state for this sub-test +sed -i.bak '/last_decay_at/d' "$CDIR2/instincts/global/stale-decay-candidate.yaml" 2>/dev/null +rm -f "$CDIR2/instincts/global/stale-decay-candidate.yaml.bak" +OUT9=$(CORTEX_DIR="$CDIR2" "$RUNNER" --dry-run 2>&1) +RC9=$? +if [ "$RC9" -eq 0 ] && [ ! -f "$CDIR2/.review-digest.json" ] && [ ! -f "$CDIR2/.last-distill" ]; then + pass "--dry-run exits 0 and writes no digest/markers" +else + fail "--dry-run left writes behind or exited non-zero (rc=$RC9)" + echo "$OUT9" +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ]