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 ]