diff --git a/README.md b/README.md
index 5a1937a..b9e1ed2 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,8 @@ The harness ships a marketplace of [plugins](plugins/) (Claude Code / Pi skills,
└── a Gap becomes ──┘
a hypothesis
trail ── records goal · hypothesis · alternatives · outcome (git trailers)
+ atlas ── indexes every artefact; its frontier seeds the next run (the loop)
+ tend ── runs the checks on a cadence and self-heals (keeps it running)
```
| Stage | Command | What it does |
@@ -73,6 +75,9 @@ The harness ships a marketplace of [plugins](plugins/) (Claude Code / Pi skills,
| **do** | `/investigate` | *Run* the question, don't read about it. **Frame** it into 2-5 falsifiable hypotheses, **fan out** one contained agent per hypothesis (the swarm), **judge** the outcomes adversarially against their evidence, **record** everything to the trail. |
| **show** | `/present` | Turn a result into a served, visual presentation: pick the medium per finding (Manim animation · marimo app · static figure · served notebook), render on the right compute, reachable over Tailscale. |
| **trail** | `trail` | The decision graph: hypotheses, choices, roads not taken, and outcomes recorded as git-commit trailers. Reconstructs from `git log` alone (no LLM), and renders as a B&W graph + tempo timeline with open hypotheses flagged as awaiting a verdict. |
+| **do (quantitative)** | `/modeling` | *Do* the quantitative science: turn data (a file, a simulation, or a digitized figure) into a fitted, model-selected, uncertainty-quantified law: candidate models, parameter covariance, AIC/BIC + cross-validation, symbolic regression. The quantitative twin of `/investigate`; composes the Axiomatic tools. |
+| **compound** | `atlas` | The corpus that makes the arc *compound*: indexes every study, investigation, map and trail into one B&W page and collects their gaps and frontiers into one queue the next run pulls from. The agent arXiv. |
+| **continuity** | `tend` | The layer that turns the arc from a line into a *loop*: register checks that should keep passing, run them on a cadence, record drift, and (armed) spawn a contained agent to self-heal, so work stays healthy and the frontier re-opens without a human in the hot path. |
```bash
/study arxiv.org/abs/2601.06712 # understand: a paper → dossier in the atlas
@@ -82,7 +87,7 @@ trail # see the decision graph reconstruct fr
/present # show: render the surviving result, serve it
```
-`/delve` conducts understand→show in one shot; `/map` and `/explore` render a whole repo into a one-page dossier. The full set lives in **[`plugins/`](plugins/)**: `science-writing` (verify-citations, peer-review, rebuttal, arxiv-prep), `tikz`, `manim`, `marimo`, and `writing-styles`.
+`/delve` conducts understand→show in one shot; `/map` and `/explore` render a whole repo into a one-page dossier. `atlas` indexes the whole corpus so the frontier of one run seeds the next, and `tend` keeps the checks healthy over time; together they close the arc into a loop. The full set lives in **[`plugins/`](plugins/)**: `science-writing` (verify-citations, peer-review, rebuttal, arxiv-prep), `tikz`, `manim`, `marimo`, `writing-styles`, and `modeling` (quantitative fits).
---
@@ -345,7 +350,7 @@ When `anu init` finds an existing config it offers three strategies: **merge** (
State & data
-anu exposes the repo at `~/.local/share/anu/` and stores runtime state there: `swarms/` (metadata, mailboxes), `reviews/` (cached summaries per SHA), `mesh/` (device cache), `box/` (contained-agent state; `box/claude` holds credentials, gitignored). The **atlas** at `~/.anu/atlas/` holds dossiers and investigations; the decision **trail** at `~/.anu/trail/`. The installer link manifest lives at `~/.local/state/anu/manifest` so `anu unlink` restores configs cleanly. All runtime state is gitignored; never commit it.
+anu exposes the repo at `~/.local/share/anu/` and stores runtime state there: `swarms/` (metadata, mailboxes), `reviews/` (cached summaries per SHA), `mesh/` (device cache), `box/` (contained-agent state; `box/claude` holds credentials, gitignored). The **atlas** at `~/.anu/atlas/` holds dossiers and investigations, with `atlas` rendering one index over all of it at `~/.anu/atlas/index.html`; the decision **trail** at `~/.anu/trail/`; and `tend` keeps watch state under `~/.local/share/anu/tend/`. The installer link manifest lives at `~/.local/state/anu/manifest` so `anu unlink` restores configs cleanly. All runtime state is gitignored; never commit it.
diff --git a/config/bash/bin/swarm b/config/bash/bin/swarm
index 82b85dc..ae76e0a 100755
--- a/config/bash/bin/swarm
+++ b/config/bash/bin/swarm
@@ -1,5 +1,10 @@
#!/usr/bin/env bash
# Standalone wrapper so `swarm` is callable as a command (not just a function)
# This lets AI agents inside Claude Code run `swarm send`, `swarm collect`, etc.
-source "${ANU_PATH:-$HOME/.local/share/anu}/config/bash/fns/swarm"
+ANU="${ANU_PATH:-$HOME/.local/share/anu}"
+# swarm() guards its deps through core (_anu_require); source core first, exactly
+# as bin/tend does, or every non-help subcommand dies here with
+# "_anu_require: command not found" on this non-interactive path.
+source "$ANU/config/bash/fns/core"
+source "$ANU/config/bash/fns/swarm"
swarm "$@"
diff --git a/config/bash/bin/tend b/config/bash/bin/tend
new file mode 100755
index 0000000..59623e9
--- /dev/null
+++ b/config/bash/bin/tend
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# Standalone wrapper so `tend` is callable as a command, not just a shell
+# function, needed by the cron/launchd heartbeat (`tend cron on`), which runs
+# in a shell that has not sourced the anu fns. Mirrors bin/box, bin/delve, bin/swarm.
+ANU="${ANU_PATH:-$HOME/.local/share/anu}"
+# cron/launchd runs with a minimal PATH; make anu's tools resolvable (box/cxc for
+# the heal, jq, homebrew bins) so the headless heartbeat and its healer can run.
+export PATH="$ANU/config/bash/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
+source "$ANU/config/bash/fns/core"
+source "$ANU/config/bash/fns/tend"
+tend "$@"
diff --git a/config/bash/fns/atlas b/config/bash/fns/atlas
new file mode 100644
index 0000000..343387f
--- /dev/null
+++ b/config/bash/fns/atlas
@@ -0,0 +1,50 @@
+# ==============================================================================
+# atlas: one word, render the corpus index across all anu research artifacts.
+#
+# Every /study, /investigate, /map and trail render lands under ~/.anu/atlas
+# (and ~/.anu/trail). Each is a one-off today. `atlas` indexes them into one
+# fixed B&W page and collects their gaps, open questions and frontiers into a
+# single queue, so finished work exposes what it left open and the next
+# investigation pulls its question from there. The corpus compounds; the agent
+# arXiv. No LLM: the index IS the on-disk JSON (build.py + render.py).
+#
+# atlas build + render + open the corpus index
+# atlas open re-open the last index without rebuilding
+# atlas ls list the corpus records in the terminal
+#
+# The skill `atlas` (plugins/atlas) teaches agents to consult the frontier
+# before starting work and to leave their own gaps behind when they finish.
+# ==============================================================================
+
+_atlas_render() { #
+ python3 "$3/build.py" "$1" "$2" "$1/atlas.json" >/dev/null &&
+ python3 "$3/render.py" "$1/atlas.json" "$1/index.html" >/dev/null
+}
+
+atlas() {
+ local atlas_root="$HOME/.anu/atlas" trail_root="$HOME/.anu/trail" skill
+ skill="${ANU_PATH:-$HOME/.local/share/anu}/plugins/atlas/skills/atlas"
+
+ case "${1:-}" in
+ open)
+ if [[ -f "$atlas_root/index.html" ]]; then
+ open "$atlas_root/index.html" 2>/dev/null || xdg-open "$atlas_root/index.html" 2>/dev/null
+ else
+ echo "no atlas yet. Run: atlas"
+ fi
+ return ;;
+ ls)
+ _anu_require python3 jq || return 1
+ mkdir -p "$atlas_root"
+ [[ -f "$atlas_root/atlas.json" ]] || _atlas_render "$atlas_root" "$trail_root" "$skill" >/dev/null
+ jq -r '.records[] | " [\(.kind)]\t\(.title)"' "$atlas_root/atlas.json" 2>/dev/null \
+ || echo "no atlas yet. Run: atlas"
+ return ;;
+ esac
+
+ _anu_require python3 || return 1
+ mkdir -p "$atlas_root"
+ _atlas_render "$atlas_root" "$trail_root" "$skill" || { _anu_die "atlas: build failed"; return 1; }
+ echo "atlas → $atlas_root/index.html"
+ open "$atlas_root/index.html" 2>/dev/null || xdg-open "$atlas_root/index.html" 2>/dev/null || true
+}
diff --git a/config/bash/fns/core b/config/bash/fns/core
new file mode 100644
index 0000000..ba24051
--- /dev/null
+++ b/config/bash/fns/core
@@ -0,0 +1,48 @@
+# ==============================================================================
+# core: shared primitives for anu shell functions.
+# ==============================================================================
+# Sourced into every interactive shell (via the config/bash/fns/* glob, before
+# the commands that call it run) and by the bash test harness. This is the one
+# place anu's diagnostic vocabulary lives, so an unattended agent fails loud
+# with a clear line instead of a cryptic error three calls deep: the single
+# most common way a full-auto cxc run silently dead-ends.
+#
+# _anu_warn "msg" # yellow "anu: msg" → stderr
+# _anu_note "msg" # dim "anu: msg" → stderr
+# _anu_die "msg" # red "anu: msg" → stderr, returns 1
+# _anu_require jq tmux # 0 if all present; else names the missing tool(s)
+
+# Write a prefixed diagnostic to stderr. Colorized only when stderr is a tty,
+# so logs and pipes stay clean.
+_anu_msg() {
+ local level="$1"; shift
+ local color='' reset=''
+ if [[ -t 2 ]]; then
+ reset=$'\033[0m'
+ case "$level" in
+ err) color=$'\033[31m' ;;
+ warn) color=$'\033[33m' ;;
+ *) color=$'\033[2m' ;;
+ esac
+ fi
+ printf '%sanu:%s %s\n' "$color" "$reset" "$*" >&2
+}
+
+_anu_warn() { _anu_msg warn "$@"; }
+_anu_note() { _anu_msg note "$@"; }
+
+# Print a diagnostic and return 1. Callers chain `|| return`.
+_anu_die() { _anu_msg err "$@"; return 1; }
+
+# _anu_require TOOL [TOOL...]: ensure each named command is on PATH. On the
+# first run with any miss, print one line listing every missing tool and return
+# 1. The choke point for "a dependency isn't installed": swarm/ncn route their
+# hard requirements through here so the failure names the tool, not a symptom.
+_anu_require() {
+ local tool missing=()
+ for tool in "$@"; do
+ command -v "$tool" &>/dev/null || missing+=("$tool")
+ done
+ (( ${#missing[@]} )) || return 0
+ _anu_die "missing required tool(s): ${missing[*]}"
+}
diff --git a/config/bash/fns/ncn b/config/bash/fns/ncn
index c7c1420..0233ba4 100644
--- a/config/bash/fns/ncn
+++ b/config/bash/fns/ncn
@@ -89,7 +89,7 @@ _nc_open() {
local rcmd
case "$profile" in
box) rcmd="box bash" ;;
- cluster|apple|ssh) rcmd="ssh ${sshopts:+$sshopts }${target}" ;;
+ cluster|apple|ssh) _anu_require ssh || return 1; rcmd="ssh ${sshopts:+$sshopts }${target}" ;;
*) echo "nc/ncn: unknown profile '$profile' (cluster|apple|ssh|box)"; return 1 ;;
esac
diff --git a/config/bash/fns/swarm b/config/bash/fns/swarm
index 40af0b3..05bc7ab 100644
--- a/config/bash/fns/swarm
+++ b/config/bash/fns/swarm
@@ -93,6 +93,16 @@ _swarm_agent_ids() {
done
}
+# A one-line "available: a b c" hint for not-found diagnostics (empty if none).
+# Turns a mistyped agent id into a self-correcting message for an unattended
+# conductor instead of a dead end.
+_swarm_agents_hint() {
+ local ids
+ ids=$(_swarm_agent_ids "$@" 2>/dev/null | tr '\n' ' ')
+ ids="${ids% }"
+ [[ -n "$ids" ]] && printf 'available: %s' "$ids"
+}
+
# Generate a short swarm ID
_swarm_gen_id() {
date +%s | shasum | head -c 8
@@ -635,7 +645,7 @@ _swarm_send() {
swarm_dir=$(_swarm_dir) || { echo "swarm send: no active swarm in this window."; return 1; }
# Validate target exists
- [[ -f "$swarm_dir/agents/$target.json" ]] || { echo "swarm send: agent '$target' not found."; return 1; }
+ [[ -f "$swarm_dir/agents/$target.json" ]] || { _anu_die "swarm send: no agent '$target' in this swarm. $(_swarm_agents_hint "$swarm_dir")"; return 1; }
local sender
sender=$(_swarm_whoami)
@@ -655,10 +665,16 @@ TIME: $(date -u +%Y-%m-%dT%H:%M:%SZ)
$message
EOF
- # Inject into pane (immediacy)
- _swarm_send_keys "$target" "$message"
+ # Inject into pane (immediacy). The mailbox write above is the durable
+ # channel; report honestly if the live injection couldn't land.
+ local injected=0
+ _swarm_send_keys "$target" "$message" && injected=1
command -v _agentlog_append &>/dev/null && _agentlog_append "$(_swarm_current_id 2>/dev/null)" "$target" "send" "[$sender→$target] $message"
- echo "swarm: sent to $target"
+ if (( injected )); then
+ echo "swarm: sent to $target"
+ else
+ _anu_warn "queued to $target's mailbox; live pane injection failed (agent pane gone? check: swarm status)"
+ fi
}
# Broadcast message to all agents in the current swarm
@@ -732,7 +748,7 @@ _swarm_capture() {
local swarm_dir
swarm_dir=$(_swarm_dir) || { echo "swarm capture: no active swarm."; return 1; }
- [[ -f "$swarm_dir/agents/$agent.json" ]] || { echo "swarm capture: agent '$agent' not found."; return 1; }
+ [[ -f "$swarm_dir/agents/$agent.json" ]] || { _anu_die "swarm capture: no agent '$agent' in this swarm. $(_swarm_agents_hint "$swarm_dir")"; return 1; }
_swarm_capture_pane "$agent" > "$swarm_dir/results/$agent.md"
echo "swarm: captured $agent → results/$agent.md"
@@ -746,7 +762,7 @@ _swarm_inspect() {
local swarm_dir
swarm_dir=$(_swarm_dir) || { echo "swarm inspect: no active swarm."; return 1; }
- [[ -f "$swarm_dir/agents/$agent.json" ]] || { echo "swarm inspect: agent '$agent' not found."; return 1; }
+ [[ -f "$swarm_dir/agents/$agent.json" ]] || { _anu_die "swarm inspect: no agent '$agent' in this swarm. $(_swarm_agents_hint "$swarm_dir")"; return 1; }
_swarm_colors
local role device cmd
@@ -2882,6 +2898,11 @@ swarm() {
local subcmd="${1:-help}"
shift 2>/dev/null
+ # Fail loud if the hard runtime deps are missing (help still works without).
+ if [[ "$subcmd" != help && "$subcmd" != --help && "$subcmd" != -h ]]; then
+ _anu_require jq tmux || return 1
+ fi
+
case "$subcmd" in
start) _swarm_start "$@" ;;
star) _swarm_star "$@" ;;
diff --git a/config/bash/fns/tend b/config/bash/fns/tend
new file mode 100644
index 0000000..5859060
--- /dev/null
+++ b/config/bash/fns/tend
@@ -0,0 +1,268 @@
+# ==============================================================================
+# tend: keep work healthy over time. anu's continuity / autonomy layer.
+#
+# The research arc (find -> understand -> do -> show) is otherwise a line of
+# commands you re-invoke. tend makes it a LOOP that keeps running: register
+# checks that should keep passing, run them on a cadence, record the drift, and
+# when armed, spawn a CONTAINED agent to self-heal the moment one breaks.
+#
+# tend status: every watch, its last result, its drift
+# tend add [opts] -- register a check (runs in the current repo)
+# --every cadence: 30m, 1h, 2h, 1d, or seconds (default 1h)
+# --heal "" shell command to run on failure (or cxc for an agent)
+# --auto self-heal automatically on failure (default: surface it)
+# tend run [name] run all due watches now (or just one)
+# tend watch live cockpit: re-run due watches on a loop
+# tend heal spawn the healer for a failing watch now
+# tend log the watch's history (drift over time)
+# tend dash render + open the B&W health dashboard
+# tend rm remove a watch
+# tend cron on|off install/remove the headless heartbeat (cron)
+#
+# State: ~/.local/share/anu/tend/.json. The skill `tend` (plugins/tend)
+# teaches what is worth tending and when to heal vs surface to a human.
+# ==============================================================================
+
+_tend_dir() { printf '%s/anu/tend' "${XDG_DATA_HOME:-$HOME/.local/share}"; }
+_tend_file() { printf '%s/%s.json' "$(_tend_dir)" "$1"; }
+_tend_skill() { printf '%s/plugins/tend/skills/tend' "${ANU_PATH:-$HOME/.local/share/anu}"; }
+_tend_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
+
+# Parse a duration (30m/2h/1d/90s/3600) into seconds.
+_tend_secs() {
+ local d="${1:-3600}" n mult
+ case "$d" in
+ *s) n="${d%s}"; mult=1 ;;
+ *m) n="${d%m}"; mult=60 ;;
+ *h) n="${d%h}"; mult=3600 ;;
+ *d) n="${d%d}"; mult=86400 ;;
+ *) n="$d"; mult=1 ;;
+ esac
+ [[ "$n" =~ ^[0-9]+$ ]] || { echo 3600; return; } # fractional / garbage -> 1h
+ echo "$(( n * mult ))"
+}
+
+# Human-format a duration in seconds.
+_tend_human() {
+ local s="${1:-0}"
+ if (( s >= 86400 )); then echo "$(( s/86400 ))d"
+ elif (( s >= 3600 )); then echo "$(( s/3600 ))h"
+ elif (( s >= 60 )); then echo "$(( s/60 ))m"
+ else echo "${s}s"; fi
+}
+
+# Seconds since an ISO-8601 UTC timestamp (handles GNU and BSD date).
+_tend_age() {
+ local ts="$1" then now
+ [[ -z "$ts" || "$ts" == "null" ]] && { echo 999999999; return; }
+ then=$(date -u -d "$ts" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$ts" +%s 2>/dev/null)
+ [[ -z "$then" ]] && { echo 999999999; return; }
+ now=$(date -u +%s)
+ echo "$(( now - then ))"
+}
+
+_tend_help() {
+ sed -n '2,23p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
+}
+
+# --- register ----------------------------------------------------------------
+_tend_add() {
+ local name="$1"; shift 2>/dev/null
+ [[ -z "$name" || "$name" == --* ]] && { _anu_die "usage: tend add [--every D] [--heal CMD|cxc] [--auto] -- "; return 1; }
+ local every="1h" heal="" auto="false"
+ local -a cmd=()
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --every) [[ $# -ge 2 ]] || { _anu_die "tend add: --every needs a value"; return 1; }; every="$2"; shift 2 ;;
+ --heal) [[ $# -ge 2 ]] || { _anu_die "tend add: --heal needs a value"; return 1; }; heal="$2"; shift 2 ;;
+ --auto) auto="true"; shift ;;
+ --) shift; cmd=("$@"); break ;;
+ *) _anu_die "tend add: unexpected '$1'. Put the command after --, e.g. tend add $name -- make test"; return 1 ;;
+ esac
+ done
+ [[ ${#cmd[@]} -eq 0 ]] && { _anu_die "tend add: no command. Usage: tend add $name -- "; return 1; }
+ local root repo secs f cmdq
+ root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
+ repo="$(basename "$root")"
+ secs="$(_tend_secs "$every")"
+ # store the command so it round-trips through `bash -c` with quoting intact
+ printf -v cmdq '%q ' "${cmd[@]}"; cmdq="${cmdq% }"
+ mkdir -p "$(_tend_dir)"
+ f="$(_tend_file "$name")"
+ jq -n --arg name "$name" --arg repo "$repo" --arg root "$root" \
+ --arg cmd "$cmdq" --argjson every "$secs" --arg heal "$heal" \
+ --argjson auto "$auto" --arg created "$(_tend_now)" '
+ {name:$name, repo:$repo, root:$root, cmd:$cmd, every:$every, heal:$heal,
+ auto:$auto, created:$created, last_run:null, last_status:"unknown",
+ last_ms:0, last_output:"", healing_since:null, history:[]}' > "$f" \
+ && echo "tend: watching '$name': $repo · ${cmd[*]} · every $(_tend_human "$secs")${heal:+ · heal=$heal}$([[ $auto == true ]] && echo ' · auto')"
+}
+
+# --- run ---------------------------------------------------------------------
+_tend_run_one() {
+ local f; f="$(_tend_file "$1")"
+ [[ -f "$f" ]] || { _anu_die "tend: no watch '$1' (see: tend)"; return 1; }
+ local root cmd auto heal every out rc start end ms status now trimmed tmp
+ root="$(jq -r '.root' "$f")"; cmd="$(jq -r '.cmd' "$f")"
+ auto="$(jq -r '.auto' "$f")"; heal="$(jq -r '.heal' "$f")"; every="$(jq -r '.every' "$f")"
+ start="$(date +%s)"
+ out="$(cd "$root" 2>/dev/null && bash -c "$cmd" 2>&1)"; rc=$?
+ end="$(date +%s)"; ms=$(( (end - start) * 1000 ))
+ [[ $rc -eq 0 ]] && status=pass || status=fail
+ now="$(_tend_now)"
+ trimmed="$(printf '%s\n' "$out" | tail -40)"
+ tmp="$(mktemp)"
+ jq --arg t "$now" --arg s "$status" --argjson ms "$ms" --arg out "$trimmed" '
+ .last_run=$t | .last_status=$s | .last_ms=$ms | .last_output=$out |
+ .history = ((.history // []) + [{t:$t, status:$s, ms:$ms}] | .[-50:])' "$f" > "$tmp" && mv "$tmp" "$f"
+ if [[ "$status" == pass ]]; then
+ # clear any in-flight heal marker once the check is green again
+ tmp="$(mktemp)"; jq '.healing_since=null' "$f" > "$tmp" && mv "$tmp" "$f"
+ echo "tend ✓ $1 (${ms}ms)"
+ else
+ _anu_warn "tend ✗ $1 failed"
+ if [[ "$auto" == "true" && -n "$heal" && "$heal" != "null" ]]; then
+ # debounce: don't stack healers while one is still in flight (a fix can
+ # take longer than the cadence). Re-heal only after a cooldown lapses.
+ local hsince hage cooldown
+ hsince="$(jq -r '.healing_since // ""' "$f")"
+ hage="$(_tend_age "$hsince")"
+ cooldown=$(( every > 1800 ? every : 1800 ))
+ if [[ -n "$hsince" && "$hsince" != "null" ]] && (( hage < cooldown )); then
+ echo "tend: heal already in flight for '$1' ($(_tend_human "$hage") ago); not re-spawning"
+ else
+ tmp="$(mktemp)"; jq --arg t "$now" '.healing_since=$t' "$f" > "$tmp" && mv "$tmp" "$f"
+ echo "tend: auto-healing '$1'…"; _tend_heal "$1"
+ fi
+ elif [[ -n "$heal" && "$heal" != "null" ]]; then
+ echo "tend: run tend heal $1 to fix"
+ fi
+ fi
+}
+
+_tend_run() {
+ [[ -n "$1" ]] && { _tend_run_one "$1"; return; }
+ local d f name last every age any=0
+ d="$(_tend_dir)"
+ for f in "$d"/*.json; do
+ [[ -f "$f" ]] || continue
+ [[ "$(basename "$f")" == tend.json ]] && continue
+ any=1
+ name="$(jq -r '.name' "$f")"; last="$(jq -r '.last_run' "$f")"; every="$(jq -r '.every' "$f")"
+ age="$(_tend_age "$last")"
+ (( age >= every )) && _tend_run_one "$name"
+ done
+ (( any )) || echo "tend: no watches yet. Add one: tend add -- "
+}
+
+# --- status ------------------------------------------------------------------
+_tend_status() {
+ local d; d="$(_tend_dir)"
+ compgen -G "$d/*.json" >/dev/null 2>&1 || { echo "tend: no watches yet. Add one: tend add -- "; return; }
+ local f name repo status last every age agestr icon cmd
+ for f in "$d"/*.json; do
+ [[ -f "$f" ]] || continue
+ [[ "$(basename "$f")" == tend.json ]] && continue
+ name="$(jq -r '.name' "$f")"; repo="$(jq -r '.repo' "$f")"; status="$(jq -r '.last_status' "$f")"
+ last="$(jq -r '.last_run' "$f")"; cmd="$(jq -r '.cmd' "$f")"
+ case "$status" in pass) icon="✓ ok ";; fail) icon="✗ FAIL";; *) icon="· new ";; esac
+ age="$(_tend_age "$last")"; agestr="$(_tend_human "$age")"; (( age >= 999999999 )) && agestr="never"
+ printf ' %-18s %-12s %-7s %-7s %s\n' "$name" "$repo" "$icon" "$agestr" "$cmd"
+ done
+}
+
+# --- heal --------------------------------------------------------------------
+_tend_heal_brief() {
+ local f; f="$(_tend_file "$1")"; [[ -f "$f" ]] || return 1
+ local cmd root out
+ cmd="$(jq -r '.cmd' "$f")"; root="$(jq -r '.root' "$f")"; out="$(jq -r '.last_output // ""' "$f")"
+ printf 'A tended check is failing and needs a fix. Repo: %s. The check is: %s . Recent output:\n%s\nFind the cause and fix it so the check passes again; make a focused commit and do not touch unrelated code. When done, %s must pass.' "$root" "$cmd" "$out" "$cmd"
+}
+
+_tend_heal_run() { # : spawn a contained healer
+ local root="$1" brief="$2"; brief="${brief//\'/}"
+ if [[ -n "$TMUX" ]] && command -v tmux &>/dev/null; then
+ local pane
+ pane="$(tmux split-window -h -P -F '#{pane_id}' -c "$root" 2>/dev/null)"
+ if [[ -n "$pane" ]]; then
+ tmux send-keys -t "$pane" "cxc '${brief}'" Enter
+ echo "tend: healer spawned in pane $pane (contained cxc)"; return 0
+ fi
+ fi
+ if command -v box &>/dev/null; then
+ echo "tend: healing headless in a contained box…"
+ ( cd "$root" 2>/dev/null && box claude --dangerously-skip-permissions -p "${brief}" )
+ return $?
+ fi
+ _anu_warn "tend: no runtime to heal (need tmux+box, or box). Brief follows:"
+ printf '%s\n' "$brief"; return 1
+}
+
+_tend_heal() {
+ local f; f="$(_tend_file "$1")"; [[ -f "$f" ]] || { _anu_die "tend: no watch '$1'"; return 1; }
+ local root heal; root="$(jq -r '.root' "$f")"; heal="$(jq -r '.heal' "$f")"
+ if [[ -n "$heal" && "$heal" != "cxc" && "$heal" != "null" ]]; then
+ echo "tend: running heal command for '$1'…"
+ ( cd "$root" 2>/dev/null && bash -c "$heal" ); return $?
+ fi
+ _tend_heal_run "$root" "$(_tend_heal_brief "$1")"
+}
+
+# --- misc --------------------------------------------------------------------
+_tend_log() {
+ local f; f="$(_tend_file "$1")"; [[ -f "$f" ]] || { _anu_die "tend: no watch '$1'"; return 1; }
+ jq -r '.history[] | " \(.t) \(.status) \(.ms)ms"' "$f"
+}
+
+_tend_rm() {
+ local f; f="$(_tend_file "$1")"
+ [[ -f "$f" ]] || { _anu_die "tend: no watch '$1'"; return 1; }
+ rm -f "$f" && echo "tend: removed '$1'"
+}
+
+_tend_watch_loop() {
+ echo "tend watch: running due checks every 30s (Ctrl-C to stop)"
+ while true; do _tend_run; sleep 30; done
+}
+
+_tend_dash() {
+ _anu_require python3 || return 1
+ local d skill out; d="$(_tend_dir)"; skill="$(_tend_skill)"; out="$d/index.html"
+ mkdir -p "$d"
+ python3 "$skill/build.py" "$d" "$d/tend.json" >/dev/null &&
+ python3 "$skill/render.py" "$d/tend.json" "$out" >/dev/null \
+ || { _anu_die "tend: dash build failed"; return 1; }
+ echo "tend dash → $out"
+ open "$out" 2>/dev/null || xdg-open "$out" 2>/dev/null || true
+}
+
+_tend_cron() {
+ _anu_require crontab || return 1
+ local bin line
+ bin="${ANU_PATH:-$HOME/.local/share/anu}/config/bash/bin/tend"
+ line="*/30 * * * * $bin run >/dev/null 2>&1 # anu-tend"
+ case "${1:-}" in
+ on) ( crontab -l 2>/dev/null | grep -v '# anu-tend'; echo "$line" ) | crontab - \
+ && echo "tend: heartbeat installed (cron, every 30m) → $bin run" ;;
+ off) ( crontab -l 2>/dev/null | grep -v '# anu-tend' ) | crontab - \
+ && echo "tend: heartbeat removed" ;;
+ *) echo "usage: tend cron on|off" ;;
+ esac
+}
+
+tend() {
+ local sub="${1:-status}"; shift 2>/dev/null
+ case "$sub" in
+ add) _anu_require jq || return 1; _tend_add "$@" ;;
+ run) _anu_require jq || return 1; _tend_run "$@" ;;
+ watch|loop) _anu_require jq || return 1; _tend_watch_loop ;;
+ heal) _anu_require jq || return 1; _tend_heal "$@" ;;
+ log) _anu_require jq || return 1; _tend_log "$@" ;;
+ rm|remove) _tend_rm "$@" ;;
+ dash) _tend_dash ;;
+ cron) _tend_cron "$@" ;;
+ status|ls|"") _anu_require jq || return 1; _tend_status ;;
+ help|-h|--help) _tend_help ;;
+ *) _anu_die "tend: unknown command '$sub' (try: tend help)"; return 1 ;;
+ esac
+}
diff --git a/plugins/.claude-plugin/marketplace.json b/plugins/.claude-plugin/marketplace.json
index 431d91c..3d3b399 100644
--- a/plugins/.claude-plugin/marketplace.json
+++ b/plugins/.claude-plugin/marketplace.json
@@ -73,6 +73,21 @@
"name": "investigate",
"source": "./investigate",
"description": "Run the research loop, don't just record it: the doing-stage twin of /study (understand) and /present (show). Turn a question (or a /study gap) into falsifiable hypotheses, test each in its own git worktree agent (one per hypothesis — cxc contained by default, cxx host when they share a toolchain — the anu swarm), adversarially verify the outcomes against their real evidence, and render one fixed-template HTML investigation in the atlas (~/.anu/atlas/investigations/): the verdict, what was learned, every hypothesis with its prediction/evidence/verdict, the roads not taken, the open frontier awaiting a human, and a link to the decision trail. Launch and watch a whole run in one shot — `investigate ` opens a window with the swarm plus a live `investigate watch` cockpit (the dig/delve twin), or draw the same data as a browser matrix with `trail swarm watch`. Execution is the swarm; the decision record is `trail` (git trailers, no LLM). Composes trail + swarm + box; command /investigate, launcher `investigate`, worker skill running-experiments."
+ },
+ {
+ "name": "modeling",
+ "source": "./modeling",
+ "description": "Do the quantitative science, don't just describe it: turn data (a file, a simulation, or a digitized figure) into a fitted, model-selected, uncertainty-quantified result. Frame candidate models, fit them with parameter covariance, select on evidence (AIC/BIC + cross-validation rather than in-sample fit), and discover the functional form when it's unknown (symbolic regression). Composes the Axiomatic model-fitting/equation-discovery tools (AxModelFitter / AxEquationExplorer / AxPlotToData / AxArgmin), ncn for heavy compute, and trail/present to record and show. The quantitative twin of /investigate; command /modeling, skill modeling."
+ },
+ {
+ "name": "atlas",
+ "source": "./atlas",
+ "description": "The corpus memory that makes anu's research compound: index every /study, /investigate, /map and trail render under ~/.anu/atlas into one fixed B&W page, and collect their gaps, open questions and frontiers into a single queue so finished work exposes what it left open and the next run pulls its question from there. A pure render (build.py + render.py, no LLM); the skill teaches agents to consult the frontier before starting and to deposit their own gaps when they finish. The agent arXiv. Command /atlas, shell command atlas, skill atlas."
+ },
+ {
+ "name": "tend",
+ "source": "./tend",
+ "description": "Keep work healthy over time — anu's continuity layer. Register checks that should keep passing (tests, a built artifact, an invariant, a benchmark, a deploy, or a research frontier going stale), run them on a cadence, record the drift, and — when armed — spawn a contained agent (cxc) to self-heal the moment one breaks. Turns the find->understand->do->show arc from commands a human re-invokes into a loop that keeps running, with a B&W health dashboard and a headless cron heartbeat. Shell command tend (add/run/watch/heal/dash/cron); the skill teaches what is worth tending and when to heal vs surface to a human."
}
]
}
diff --git a/plugins/atlas/.claude-plugin/plugin.json b/plugins/atlas/.claude-plugin/plugin.json
new file mode 100644
index 0000000..9cc5eb5
--- /dev/null
+++ b/plugins/atlas/.claude-plugin/plugin.json
@@ -0,0 +1,11 @@
+{
+ "name": "atlas",
+ "version": "0.1.0",
+ "description": "The corpus memory that makes anu's research compound. Every /study, /investigate, /map and trail render is a one-off artifact under ~/.anu/atlas; atlas indexes them into one fixed B&W page and collects their gaps, open questions and frontiers into a single queue, so finished work exposes what it left open and the next run pulls its question from there. A pure render (build.py + render.py, no LLM); the skill teaches agents to consult the frontier before starting and to leave their own gaps behind when they finish. The agent arXiv. Command /atlas, shell command atlas, skill atlas. Use when the user wants to see everything studied/investigated so far, avoid redoing work, or pick the next question from the open frontier.",
+ "author": {
+ "name": "Aadarsh Agarwal",
+ "url": "https://github.com/aadarwal"
+ },
+ "license": "MIT",
+ "keywords": ["anu", "atlas", "corpus", "agent-arxiv", "knowledge", "frontier", "research", "study", "investigate", "compounding", "memory"]
+}
diff --git a/plugins/atlas/commands/atlas.md b/plugins/atlas/commands/atlas.md
new file mode 100644
index 0000000..13abf90
--- /dev/null
+++ b/plugins/atlas/commands/atlas.md
@@ -0,0 +1,28 @@
+---
+description: Render the corpus index, every /study, /investigate, /map and trail in one B&W page, with their gaps, open questions and frontiers collected into one queue. The agent arXiv that makes the research arc a loop.
+---
+
+Render and open the corpus index for everything anu has produced.
+
+The fast path is the shell command. Run it directly:
+
+```bash
+atlas # build + render + open the corpus index
+atlas open # re-open the last index without rebuilding
+atlas ls # list the corpus records in the terminal
+```
+
+Then follow the **atlas** skill
+(`~/.local/share/anu/plugins/atlas/skills/atlas/SKILL.md`) for the discipline of
+*using* the corpus so it compounds:
+
+1. **Before starting work, consult the frontier.** Check whether the paper was
+ already `/study`'d or the question already sits in the frontier as a gap or an
+ investigation's next-step; pull it from there instead of starting cold.
+2. **When you finish, leave your edges behind.** End a `/study` with real `gap`
+ and `open_questions`; end an `/investigate` with a real `frontier`. Those are
+ the inputs to the next run.
+
+The index records only real edges (study→present, investigate→trail) and never
+infers a gap→hypothesis link. If you acted on a frontier item, cite its source
+id in your new artifact so the edge becomes real.
diff --git a/plugins/atlas/skills/atlas/SKILL.md b/plugins/atlas/skills/atlas/SKILL.md
new file mode 100644
index 0000000..45c9c3a
--- /dev/null
+++ b/plugins/atlas/skills/atlas/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: atlas
+description: The corpus memory that makes anu's research compound. Every /study, /investigate, /map and trail render is a one-off artifact under ~/.anu/atlas; `atlas` indexes them into one fixed B&W page and collects their gaps, open questions and frontiers into a single queue, so finished work exposes what it left open and the next run pulls its question from there. The agent arXiv: a research group's accumulated, navigable memory. Use when the user wants to see everything studied/investigated so far, find what's already been done before starting, or pick the next question from the open frontier.
+---
+
+# atlas: the corpus, and why it compounds
+
+A research group's value is not any single result; it is the **accumulated body
+of work** where findings cite and build on findings. anu produces durable
+artifacts (a `/study` dossier, an `/investigate` verdict, a `/map`, a `trail`),
+but each lands alone in `~/.anu/atlas`. The atlas is the layer that turns that
+pile into a **corpus**: one index over everything, and one **frontier**: every
+gap, open question and next-step the finished work left behind, in a single
+queue the next run draws from.
+
+`atlas` is a **pure render** (`build.py` + `render.py`, no LLM): the corpus *is*
+the on-disk JSON. Run it; this skill is the discipline of *using* it.
+
+```
+atlas build + render + open the corpus index
+atlas open re-open the last index without rebuilding
+atlas ls list the corpus records in the terminal
+```
+
+## The discipline: close the loop
+
+The arc is `find → understand → do → show`. The atlas is what makes it a *loop*
+instead of a line. Two habits make the corpus compound:
+
+**Before you start, consult the frontier.** Don't begin a study or an
+investigation cold. Run `atlas` (or read `~/.anu/atlas/atlas.json`) first:
+- Has this paper already been `/study`'d? Build on the dossier, don't redo it.
+- Is the question you're about to ask already sitting in the **frontier** as a
+ `/study` gap or another investigation's open frontier? Pull it from there: a
+ gap that became your hypothesis is exactly how one result seeds the next.
+- Is there a related investigation whose verdict changes your framing? Cite it.
+
+**When you finish, leave your edges behind.** The frontier is only as good as
+what each run deposits into it. A `/study` must end with real `gap` and
+`open_questions`; an `/investigate` must end with a real `frontier` (the roads
+not taken, the next test). Those fields are the *inputs to the next run*, not
+decoration. Write them honestly; vague frontiers starve the loop.
+
+## What it links, and what it refuses to
+
+The index records **only real edges**: a `/study` to its `/present` demo (same
+paper folder), an `/investigate` to its `trail`. It does **not** infer a
+"this gap became that hypothesis" link; that would be a fabricated claim about
+intent. Instead both ends surface in the shared frontier, where the connection
+is visible but honest. If you *do* act on a specific frontier item, say so in
+your new artifact (cite the source id); that makes the edge real, and the next
+`atlas` build can show it.
+
+## Rules
+- **Don't fabricate the corpus.** The index reflects what's on disk; if little
+ has been done, the honest atlas is short.
+- **The frontier is the deliverable of the loop**, not a footnote. Finished work
+ that records no open questions has broken the chain.
+- **Consult before you create; deposit when you're done.** That is the whole
+ point: knowledge that compounds instead of restarting.
diff --git a/plugins/atlas/skills/atlas/build.py b/plugins/atlas/skills/atlas/build.py
new file mode 100644
index 0000000..3ea3eae
--- /dev/null
+++ b/plugins/atlas/skills/atlas/build.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+"""Build the atlas corpus index: scan ~/.anu/atlas and ~/.anu/trail, emit atlas.json.
+
+Usage: build.py
+
+No LLM, no hidden state: the corpus IS the on-disk artifacts. Every /study,
+/investigate, /map and trail render becomes one record; their gaps, open
+questions and frontiers are collected into one frontier queue, so finished work
+exposes what it left open and the next investigation can pull from it. That is
+how the atlas compounds: a research group's accumulated, navigable memory.
+
+Only real edges are recorded (study->present, investigation->trail). The cross-
+arc "a gap became a hypothesis" link is not inferred. It would be a fabricated
+claim; instead both ends surface in the shared frontier, honestly.
+"""
+import json
+import pathlib
+import sys
+
+
+def load(p):
+ try:
+ x = json.loads(pathlib.Path(p).read_text())
+ return x if isinstance(x, dict) else None
+ except Exception:
+ return None
+
+
+def text_list(v):
+ """Normalize a field that may be a list of strings/objects into [str]."""
+ out = []
+ if isinstance(v, list):
+ for x in v:
+ if isinstance(x, str):
+ out.append(x.strip())
+ elif isinstance(x, dict):
+ t = x.get("text") or x.get("title") or x.get("question") or x.get("q")
+ if t:
+ out.append(str(t).strip())
+ elif isinstance(v, str):
+ out.append(v.strip())
+ return [s for s in out if s]
+
+
+def repo_name(d, fallback):
+ """map.json / trail.json carry `repo` as an object {name, path, ...}."""
+ r = d.get("repo")
+ if isinstance(r, dict):
+ return r.get("name") or fallback
+ if isinstance(r, str) and r:
+ return r
+ return fallback
+
+
+def main():
+ if len(sys.argv) != 4:
+ sys.exit("usage: build.py ")
+ atlas = pathlib.Path(sys.argv[1])
+ trail = pathlib.Path(sys.argv[2])
+ out = pathlib.Path(sys.argv[3])
+
+ records, frontier, edges = [], [], []
+
+ def add_frontier(kind, rid, title, items):
+ for t in items:
+ frontier.append({"kind": kind, "id": rid, "title": title, "text": t})
+
+ # --- studies (papers) ---
+ for sj in sorted(atlas.glob("papers/*/study.json")):
+ d = load(sj)
+ if not d:
+ continue
+ rid = sj.parent.name
+ paper = d.get("paper") if isinstance(d.get("paper"), dict) else {}
+ title = paper.get("title") or d.get("title") or rid
+ rec = {"kind": "study", "id": rid, "title": title,
+ "summary": d.get("summary", ""), "href": f"papers/{rid}/index.html",
+ "badges": [], "stats": {}}
+ if isinstance(d.get("neighbors"), list):
+ rec["stats"]["neighbors"] = len(d["neighbors"])
+ gaps, oqs = text_list(d.get("gap")), text_list(d.get("open_questions"))
+ if gaps or oqs:
+ rec["stats"]["open"] = len(gaps) + len(oqs)
+ if (sj.parent / "present" / "index.html").exists():
+ rec["badges"].append("present")
+ edges.append({"from": rec["href"], "to": f"papers/{rid}/present/index.html",
+ "kind": "study→present"})
+ records.append(rec)
+ add_frontier("study", rid, title, gaps + oqs)
+
+ # --- investigations ---
+ for ij in sorted(atlas.glob("investigations/*/investigation.json")):
+ d = load(ij)
+ if not d:
+ continue
+ rid = ij.parent.name
+ meta = d.get("investigation") if isinstance(d.get("investigation"), dict) else {}
+ title = meta.get("question") or meta.get("title") or d.get("title") or rid
+ funnel = d.get("funnel") if isinstance(d.get("funnel"), dict) else {}
+ rec = {"kind": "investigation", "id": rid, "title": title,
+ "summary": d.get("verdict", ""), "href": f"investigations/{rid}/index.html",
+ "badges": [], "stats": dict(funnel)}
+ if (ij.parent / "trail.html").exists():
+ rec["badges"].append("trail")
+ edges.append({"from": rec["href"], "to": f"investigations/{rid}/trail.html",
+ "kind": "investigation→trail"})
+ records.append(rec)
+ add_frontier("investigation", rid, title, text_list(d.get("frontier")))
+
+ # --- maps (repo dossiers): atlas//map.json, excluding the special dirs ---
+ for mj in sorted(atlas.glob("*/map.json")):
+ if mj.parent.name in ("papers", "investigations"):
+ continue
+ d = load(mj)
+ if not d:
+ continue
+ rid = mj.parent.name
+ rec = {"kind": "map", "id": rid, "title": repo_name(d, rid),
+ "summary": d.get("summary", ""), "href": f"{rid}/index.html",
+ "badges": [], "stats": {"components": len(d.get("components") or [])}}
+ records.append(rec)
+ add_frontier("map", rid, rid, text_list(d.get("open_questions")))
+
+ # --- trails (the sibling root ~/.anu/trail/) ---
+ for tj in sorted(trail.glob("*/trail.json")):
+ d = load(tj)
+ if not d:
+ continue
+ rid = tj.parent.name
+ nodes = d.get("nodes") if isinstance(d.get("nodes"), list) else []
+ rmeta = d.get("repo") if isinstance(d.get("repo"), dict) else {}
+ records.append({"kind": "trail", "id": rid, "title": repo_name(d, rid),
+ "summary": d.get("summary") or rmeta.get("goal") or "",
+ "href": f"../trail/{rid}/index.html",
+ "badges": [], "stats": {"nodes": len(nodes)}})
+
+ counts = {}
+ for r in records:
+ counts[r["kind"]] = counts.get(r["kind"], 0) + 1
+
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(
+ {"counts": counts, "records": records, "frontier": frontier, "edges": edges},
+ indent=2))
+ print(out)
+
+
+main()
diff --git a/plugins/atlas/skills/atlas/render.py b/plugins/atlas/skills/atlas/render.py
new file mode 100644
index 0000000..37f4acf
--- /dev/null
+++ b/plugins/atlas/skills/atlas/render.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+"""Render atlas.json into index.html using the fixed template.
+
+Usage: render.py
+
+The template never varies: all per-corpus content is injected as one JSON blob
+the page renders client-side. Same contract as map/study/trail.
+"""
+import json
+import pathlib
+import sys
+
+
+def main():
+ if len(sys.argv) != 3:
+ sys.exit("usage: render.py ")
+ src, out = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
+ template = (pathlib.Path(__file__).resolve().parent / "template.html").read_text()
+ # would terminate the inline
+