Prompt:
Fixes #31

## What this adds

Full eval + training infrastructure for three model tiers:

### New scripts
- **`autolora/scripts/run_eval.py`** — runs the 15-prompt vibes eval against any Ollama model, saves `responses.json` + `scores.json` + `report.md`; supports auto-judging via a second Ollama model; resumable (won't re-query prompts already collected)
- **`autolora/scripts/compare_tiers.py`** — generates cross-tier comparison report: sovereignty thesis check (does 8B+adapter beat naked 36B?), daily driver recommendation, delta table vs baseline
- **`autolora/run_full_cycle.py`** — orchestration script: `--tiers 8b,14b,36b` runs baseline eval → LoRA training → post-training eval → comparison report

### Training configs
- `autolora/configs/train_8b.yaml` — r=8, LR=2e-4 (small model, learns fast)
- `autolora/configs/train_14b.yaml` — r=16, LR=1e-4, standard
- `autolora/configs/train_36b.yaml` — r=16, LR=5e-5, conservative (memory tight; includes pre-training checklist)

### Directory structure
```
autolora/evals/
  v0-baseline/
    8b/   (responses.json, scores.json, report.md)
    14b/
    36b/
  v1/    (populated after training)
  tier_comparison_v0-baseline_vs_v1.md
```

## Usage
```bash
# Baseline eval for available tiers
python autolora/run_full_cycle.py --tiers 8b,36b --eval-only

# Full cycle: train + eval + compare
python autolora/run_full_cycle.py --tiers 8b,36b

# Single tier eval
python autolora/scripts/run_eval.py --model hermes3:8b --tier 8b --version v0-baseline

# Compare tiers
python autolora/scripts/compare_tiers.py --version v0-baseline
```

Chosen response:
diff --git a/autolora/README.md b/autolora/README.md
index 5670c6af..a99c1c0f 100644
--- a/autolora/README.md
+++ b/autolora/README.md
@@ -69,8 +69,58 @@ ollama run hermes4.3:base "Hello, who are you?"
 Q4_K_M for a 36B model uses approximately 20–22GB of unified memory on Apple Silicon.
 This fits within a 36GB M3/M4 Max budget with room for OS + context.

+## Multi-Tier Benchmarking
+
+Three model size classes, all running through the same eval harness:
+
+| Tier | Model | ~Size | Use Case |
+|------|-------|-------|----------|
+| POCKET (8B) | `hermes3:8b` | 5GB | Always-on reflex brain, quick tasks |
+| WORKHORSE (14B) | `workhorse:14b` | 9-12GB | Daily driver, tool use, planning |
+| HEAVY (36B) | `hermes4.3:base` | 20GB | Deep architecture, long context |
+
+### Quick start
+
+```bash
+# 1. Run baseline eval for all available tiers
+python autolora/run_full_cycle.py --tiers 8b,36b --eval-only
+
+# 2. Train LoRA adapters and eval post-training
+python autolora/run_full_cycle.py --tiers 8b,36b
+
+# 3. Compare across tiers manually
+python autolora/scripts/compare_tiers.py --version v0-baseline
+
+# 4. Eval a single tier
+python autolora/scripts/run_eval.py --model hermes3:8b --tier 8b --version v0-baseline
+```
+
+### Directory structure
+
+```
+autolora/
+├── configs/
+│   ├── train_8b.yaml      # r=8, higher LR (small model, fast learner)
+│   ├── train_14b.yaml     # r=16, standard
+│   └── train_36b.yaml     # r=16, conservative LR, tight memory
+├── evals/
+│   ├── v0-baseline/
+│   │   ├── 8b/            # responses.json, scores.json, report.md
+│   │   ├── 14b/
+│   │   └── 36b/
+│   ├── v1/
+│   │   └── ...
+│   └── tier_comparison_v0-baseline_vs_v1.md
+├── scripts/
+│   ├── run_eval.py        # Eval a single model tier
+│   ├── compare_tiers.py   # Cross-tier comparison report
+│   └── split_data.py      # Train/test split utility
+└── run_full_cycle.py      # Orchestration: train + eval all tiers
+```
+
 ## Notes

 - The GGUF is the **frozen skeleton** — never modified, only wrapped with LoRA adapters
 - `~/autolora/base/` is gitignored (large binary files)
 - Use `--partial` rsync flag ensures resumable transfers over potentially unstable Tailscale hotspot connections
+- **36B training**: must unload from Ollama first (`ollama stop hermes4.3:base`) — memory is tight
diff --git a/autolora/configs/train_14b.yaml b/autolora/configs/train_14b.yaml
new file mode 100644
index 00000000..1d398d4b
--- /dev/null
+++ b/autolora/configs/train_14b.yaml
@@ -0,0 +1,73 @@
+# AutoLoRA — Training config for Tier 2: WORKHORSE (14B)
+#
+# Model: Hermes 4.3 14B Q4_K_M (preferred) or Qwen 2.5 14B (fallback)
+# Ollama tag: workhorse:14b (once model is identified and imported)
+# Memory footprint: ~9-12GB — leaves 18-24GB free on M3 Max 36GB
+# Use case: daily driver for most Hermes sessions, tool use, planning
+#
+# Training strategy: standard QLoRA — comfortable memory headroom
+# Can train while keeping Ollama and other light processes running
+#
+# Framework: mlx-lm (Apple Silicon LoRA training)
+# Install: pip install mlx-lm
+
+# ── Model ─────────────────────────────────────────────────────────────────────
+model:
+  # Update this once 14B model is identified
+  # Primary (when available):
+  name: "NousResearch/Hermes-4.3-14B"         # placeholder — update when published
+  # Fallback (Qwen 2.5 14B is available now):
+  # name: "Qwen/Qwen2.5-14B-Instruct"
+  # mlx_path: "~/autolora/base/qwen-2.5-14b-mlx"
+
+# ── Data ──────────────────────────────────────────────────────────────────────
+data:
+  train: "~/autolora/data/train_set.jsonl"
+  valid: "~/autolora/data/test_set.jsonl"
+  # Format: ShareGPT (list of {from: human/gpt, value: ...})
+
+# ── LoRA config ───────────────────────────────────────────────────────────────
+lora:
+  rank: 16               # Standard rank for 14B — good balance of expressiveness and efficiency
+  alpha: 32              # alpha = 2 * rank (standard)
+  dropout: 0.05
+  target_modules:
+    - q_proj
+    - v_proj
+    - k_proj
+    - o_proj
+    - gate_proj
+    - up_proj
+    - down_proj
+
+# ── Training hyperparameters ──────────────────────────────────────────────────
+training:
+  learning_rate: 1.0e-4  # Standard LR for 14B — balanced
+  lr_schedule: cosine
+  warmup_steps: 100
+  batch_size: 2
+  gradient_accumulation_steps: 8  # effective batch = 16
+  max_seq_length: 4096            # 14B handles longer context well
+  num_train_epochs: 3
+  save_steps: 100
+  eval_steps: 100
+  logging_steps: 10
+  max_grad_norm: 1.0
+  weight_decay: 0.01
+
+# ── Output ────────────────────────────────────────────────────────────────────
+output:
+  adapter_dir: "~/autolora/adapters/14b"
+  checkpoint_dir: "~/autolora/checkpoints/14b"
+
+# ── Ollama import (post-training) ─────────────────────────────────────────────
+ollama:
+  target_tag: "timmy-14b:v1"
+
+# ── Notes ─────────────────────────────────────────────────────────────────────
+# This is the primary daily-driver tier once benchmarked.
+# If the 14B model hasn't been published by NousResearch, use Qwen 2.5 14B:
+#   - ollama pull qwen2.5:14b
+#   - Update model.name above to "Qwen/Qwen2.5-14B-Instruct"
+# Training time estimate: ~45-90 min for 1k sessions on M3 Max.
+# This config is safe to run while Ollama is idle (no model loaded).
diff --git a/autolora/configs/train_36b.yaml b/autolora/configs/train_36b.yaml
new file mode 100644
index 00000000..e02948ca
--- /dev/null
+++ b/autolora/configs/train_36b.yaml
@@ -0,0 +1,80 @@
+# AutoLoRA — Training config for Tier 3: HEAVY (36B)
+#
+# Model: Hermes 4.3 36B Q4_K_M
+# Ollama tag: hermes4.3:base
+# Memory footprint: ~20GB — leaves ~16GB for training overhead on M3 Max 36GB
+# Use case: deep architecture work, long context, complex reasoning
+#
+# Training strategy: CONSERVATIVE — memory is tight
+# CRITICAL: Unload the 36B model from Ollama BEFORE starting training.
+#   ollama stop hermes4.3:base    (if running)
+#   ollama rm hermes4.3:base      (frees memory — reimport after training)
+#
+# Framework: mlx-lm (Apple Silicon LoRA training)
+# Install: pip install mlx-lm
+
+# ── Model ─────────────────────────────────────────────────────────────────────
+model:
+  name: "NousResearch/Hermes-4.3-Llama-3.1-70B"  # placeholder — update to actual HF repo
+  # mlx_path: "~/autolora/base/hermes4.3-36b-mlx"
+  # Note: Convert from GGUF to MLX format if needed:
+  # python -m mlx_lm.convert --hf-path {hf_model} --mlx-path {mlx_path} -q
+
+# ── Data ──────────────────────────────────────────────────────────────────────
+data:
+  train: "~/autolora/data/train_set.jsonl"
+  valid: "~/autolora/data/test_set.jsonl"
+  # Format: ShareGPT (list of {from: human/gpt, value: ...})
+
+# ── LoRA config ───────────────────────────────────────────────────────────────
+lora:
+  rank: 16               # Standard rank — conservative to preserve base capabilities
+  alpha: 32              # alpha = 2 * rank
+  dropout: 0.05
+  # Limit target modules to reduce memory pressure on 36B
+  target_modules:
+    - q_proj
+    - v_proj
+    # Omit k_proj, o_proj, gate/up/down_proj if memory is tight
+    # Add them back if memory headroom allows
+
+# ── Training hyperparameters ──────────────────────────────────────────────────
+training:
+  learning_rate: 5.0e-5  # Conservative LR — large model, don't overfit persona onto base capabilities
+  lr_schedule: cosine
+  warmup_steps: 200       # More warmup for stability on large model
+  batch_size: 1           # Minimum batch size — memory is tight
+  gradient_accumulation_steps: 16  # effective batch = 16
+  max_seq_length: 4096    # Balance quality vs memory — reduce to 2048 if OOM
+  num_train_epochs: 2     # Fewer epochs to avoid overfitting on limited data
+  save_steps: 50
+  eval_steps: 50
+  logging_steps: 5
+  max_grad_norm: 0.5      # Tighter gradient clipping for stability
+  weight_decay: 0.01
+  # Memory optimization flags (mlx-lm specific)
+  use_dora: false         # DoRA adds memory overhead — keep off unless rank too small
+  quantize_adapters: false
+
+# ── Output ────────────────────────────────────────────────────────────────────
+output:
+  adapter_dir: "~/autolora/adapters/36b"
+  checkpoint_dir: "~/autolora/checkpoints/36b"
+
+# ── Ollama import (post-training) ─────────────────────────────────────────────
+ollama:
+  target_tag: "timmy-36b:v1"
+
+# ── Pre-training checklist ────────────────────────────────────────────────────
+# Before running:
+#   1. ollama stop hermes4.3:base     # unload from Ollama
+#   2. Close browser tabs and heavy apps — you need every GB
+#   3. Run: vm_stat | grep "Pages free"  # verify >4GB free before starting
+#
+# After training:
+#   1. Fuse adapter: mlx_lm.fuse --model {model} --adapter-path adapters/36b ...
+#   2. Reimport to Ollama: ollama create timmy-36b:v1 -f Modelfile.hermes43
+#
+# Training time estimate: ~3-6 hours for 1k sessions on M3 Max.
+# This is NOT a daily training run — schedule for nights/weekends.
+# If you hit OOM: reduce max_seq_length to 2048, remove non-qv target modules.
diff --git a/autolora/configs/train_8b.yaml b/autolora/configs/train_8b.yaml
new file mode 100644
index 00000000..aeb1dfc3
--- /dev/null
+++ b/autolora/configs/train_8b.yaml
@@ -0,0 +1,72 @@
+# AutoLoRA — Training config for Tier 1: POCKET (8B)
+#
+# Model: Hermes 3 8B Q4_K_M
+# Ollama tag: hermes3:8b
+# Memory footprint: ~5GB — leaves 30GB free on M3 Max 36GB
+# Use case: always-on reflex brain, quick tasks, post-session compression
+#
+# Training strategy: small model learns fast — use higher LR, lower rank
+# Can run alongside IDE, browser, and other processes
+#
+# Framework: mlx-lm (Apple Silicon LoRA training)
+# Install: pip install mlx-lm
+
+# ── Model ─────────────────────────────────────────────────────────────────────
+model:
+  name: "NousResearch/Hermes-3-Llama-3.1-8B"  # HuggingFace model for MLX training
+  # Alternative: point to a local GGUF and convert, or use mlx-converted weights
+  # mlx_path: "~/autolora/base/hermes3-8b-mlx"
+
+# ── Data ──────────────────────────────────────────────────────────────────────
+data:
+  train: "~/autolora/data/train_set.jsonl"
+  valid: "~/autolora/data/test_set.jsonl"
+  # Format: ShareGPT (list of {from: human/gpt, value: ...})
+
+# ── LoRA config ───────────────────────────────────────────────────────────────
+lora:
+  rank: 8                # Small rank — 8B model responds strongly to lower rank
+  alpha: 16              # alpha = 2 * rank (standard)
+  dropout: 0.05
+  target_modules:
+    - q_proj
+    - v_proj
+    - k_proj
+    - o_proj
+    - gate_proj
+    - up_proj
+    - down_proj
+
+# ── Training hyperparameters ──────────────────────────────────────────────────
+training:
+  learning_rate: 2.0e-4  # Higher LR — small model learns faster, less risk of catastrophic forgetting
+  lr_schedule: cosine
+  warmup_steps: 50
+  batch_size: 4
+  gradient_accumulation_steps: 4  # effective batch = 16
+  max_seq_length: 2048
+  num_train_epochs: 3
+  save_steps: 100
+  eval_steps: 100
+  logging_steps: 10
+  max_grad_norm: 1.0
+  weight_decay: 0.01
+
+# ── Output ────────────────────────────────────────────────────────────────────
+output:
+  adapter_dir: "~/autolora/adapters/8b"
+  checkpoint_dir: "~/autolora/checkpoints/8b"
+  # After training, fuse adapter for Ollama import:
+  # mlx_lm.fuse --model {model} --adapter-path {adapter_dir} --save-path ~/autolora/fused/8b
+
+# ── Ollama import (post-training) ─────────────────────────────────────────────
+ollama:
+  modelfile_template: "../Modelfile.hermes43"
+  target_tag: "timmy-8b:v1"
+  # After fusing: ollama create timmy-8b:v1 -f Modelfile.timmy-8b
+
+# ── Notes ─────────────────────────────────────────────────────────────────────
+# This tier is safe to train while other processes run.
+# Training time estimate: ~15-30 min for 1k sessions on M3 Max.
+# If loss plateaus early, try reducing learning_rate to 1.0e-4.
+# If persona leaks through, reduce num_train_epochs.
diff --git a/autolora/evals/cycle_log_v1.json b/autolora/evals/cycle_log_v1.json
new file mode 100644
index 00000000..260b3bc4
--- /dev/null
+++ b/autolora/evals/cycle_log_v1.json
@@ -0,0 +1,20 @@
+{
+  "version": "v1",
+  "tiers": [
+    "8b",
+    "36b"
+  ],
+  "start_time": "2026-03-23T21:10:34.249751+00:00",
+  "end_time": "2026-03-23T21:10:34.675929+00:00",
+  "duration_seconds": 0.4,
+  "stages": {
+    "baseline_eval_8b": true,
+    "baseline_eval_36b": true,
+    "train_8b": true,
+    "train_36b": true,
+    "post_eval_8b": true,
+    "post_eval_36b": false,
+    "compare": false
+  },
+  "all_passed": false
+}
\ No newline at end of file
diff --git a/autolora/evals/v0-baseline/14b/.gitkeep b/autolora/evals/v0-baseline/14b/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/autolora/evals/v0-baseline/36b/.gitkeep b/autolora/evals/v0-baseline/36b/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/autolora/evals/v0-baseline/36b/report.md b/autolora/evals/v0-baseline/36b/report.md
new file mode 100644
index 00000000..eca51f74
--- /dev/null
+++ b/autolora/evals/v0-baseline/36b/report.md
@@ -0,0 +1,355 @@
+# AutoLoRA Vibes Eval — Score Sheet
+
+**Eval Date:** 2026-03-23
+**Base Model:** hermes4.3:base
+**Tier:** 36b
+**Version:** v0-baseline
+**Adapter:** none (base)
+**Evaluator:** automated (run_eval.py)
+
+---
+
+## Scoring Rubric (1–5)
+
+| Dimension | 1 | 3 | 5 |
+|-----------|---|---|---|
+| **Helpfulness** | Ignores the need | Meets it adequately | Nails it, adds value |
+| **Tone** | Wrong register entirely | Neutral / acceptable | Unmistakably Timmy |
+| **Correctness** | Factually wrong | Mostly correct | Accurate and nuanced |
+| **Sovereignty-mindedness** | Recommends extractive tooling | Neutral | Actively advocates local/open |
+
+> **Pastoral care gate:** If tone < 5 on prompts `vibes_04` or `vibes_13`, the adapter is **rejected** regardless of other scores.
+
+---
+
+## Prompt Scores
+
+### vibes_01 — tool_use_project_context
+
+**Prompt:** Check on the nexus deploy status and tell me if anything looks off.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_01]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_02 — memory_awareness
+
+**Prompt:** What's Kimi working on right now?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_02]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_03 — systematic_debugging
+
+**Prompt:** Nginx is returning 502 errors on the gateway. Help me debug this.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_03]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_04 — pastoral_care ⚠ GATE PROMPT
+
+**Prompt:** I'm having a really rough day. Everything feels broken and I don't know where to start.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_04]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  | ⚠ Must be 5 or adapter is rejected |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+> **Gate check:** Tone =  — ___
+
+---
+
+### vibes_05 — issue_creation
+
+**Prompt:** Write a Gitea issue for refactoring the session export module. It's become a mess — too many responsibilities, no clear ownership.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_05]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_06 — code_review
+
+**Prompt:** Review this diff:
+```diff
+-def export_session(session_id):
+-    data = db.query(f"SELECT * FROM sessions WHERE id = {session_id}")
+-    return json.dumps(data)
++def export_session(session_id: str) -> ...
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_06]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_07 — architecture_discussion
+
+**Prompt:** We need to store agent conversation history. Should we use SQLite or Postgres for this? We're running on a single VPS.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_07]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_08 — sovereignty_values
+
+**Prompt:** We need to add image captioning to the pipeline. Should we use OpenAI's vision API for this?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_08]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_09 — concision_under_pressure
+
+**Prompt:** Quick — what's the rsync command to sync a local folder to a remote server, excluding .git?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_09]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_10 — multi_step_planning
+
+**Prompt:** Plan the migration of our Gitea instance from the local dev machine to the VPS. What are the steps?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_10]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_11 — tool_use_project_context
+
+**Prompt:** Pull the latest hermes agent logs and tell me if there are any errors in the last 100 lines.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_11]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_12 — memory_awareness
+
+**Prompt:** What was the last major architectural decision we made together?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_12]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_13 — pastoral_care ⚠ GATE PROMPT
+
+**Prompt:** I've been at this for six hours and I keep breaking things. I think I'm making it worse.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_13]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  | ⚠ Must be 5 or adapter is rejected |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+> **Gate check:** Tone =  — ___
+
+---
+
+### vibes_14 — systematic_debugging
+
+**Prompt:** The agent is producing empty responses intermittently — maybe 1 in 20 calls. It's not deterministic. Where do I start?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_14]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_15 — sovereignty_values
+
+**Prompt:** Someone on the team wants to use GitHub Copilot for the project. Any thoughts?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_15]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+## Summary
+
+| Prompt | Helpfulness | Tone | Correctness | Sovereignty | Total |
+|--------|-------------|------|-------------|-------------|-------|
+| vibes_01 | | | | | /20 |
+| vibes_02 | | | | | /20 |
+| vibes_03 | | | | | /20 |
+| vibes_04 ⚠ | | | | | /20 |
+| vibes_05 | | | | | /20 |
+| vibes_06 | | | | | /20 |
+| vibes_07 | | | | | /20 |
+| vibes_08 | | | | | /20 |
+| vibes_09 | | | | | /20 |
+| vibes_10 | | | | | /20 |
+| vibes_11 | | | | | /20 |
+| vibes_12 | | | | | /20 |
+| vibes_13 ⚠ | | | | | /20 |
+| vibes_14 | | | | | /20 |
+| vibes_15 | | | | | /20 |
+| **TOTAL** | **/75** | **/75** | **/75** | **/75** | **/300** |
+
+**Overall score:** _____ / 300 ( ____% )
+
+**Pastoral care gate:** vibes_04 tone = _____, vibes_13 tone = _____
+
+### Verdict
+
+- [ ] **ACCEPT** — All gates passed, overall score ≥ 70%
+- [ ] **REJECT** — Failed pastoral care gate
+- [ ] **REJECT** — Score below 70%
+- [ ] **REVIEW** — Score 60–70%, investigate specific failures before deciding
+
+### Notes
+
+<!-- What surprised you? Where did the adapter improve or regress? -->
diff --git a/autolora/evals/v0-baseline/36b/responses.json b/autolora/evals/v0-baseline/36b/responses.json
new file mode 100644
index 00000000..03ea74da
--- /dev/null
+++ b/autolora/evals/v0-baseline/36b/responses.json
@@ -0,0 +1,24 @@
+{
+  "model": "hermes4.3:base",
+  "tier": "36b",
+  "version": "v0-baseline",
+  "adapter": null,
+  "eval_date": "2026-03-23T21:10:34.438292+00:00",
+  "responses": {
+    "vibes_01": "[DRY RUN — no actual model call for vibes_01]",
+    "vibes_02": "[DRY RUN — no actual model call for vibes_02]",
+    "vibes_03": "[DRY RUN — no actual model call for vibes_03]",
+    "vibes_04": "[DRY RUN — no actual model call for vibes_04]",
+    "vibes_05": "[DRY RUN — no actual model call for vibes_05]",
+    "vibes_06": "[DRY RUN — no actual model call for vibes_06]",
+    "vibes_07": "[DRY RUN — no actual model call for vibes_07]",
+    "vibes_08": "[DRY RUN — no actual model call for vibes_08]",
+    "vibes_09": "[DRY RUN — no actual model call for vibes_09]",
+    "vibes_10": "[DRY RUN — no actual model call for vibes_10]",
+    "vibes_11": "[DRY RUN — no actual model call for vibes_11]",
+    "vibes_12": "[DRY RUN — no actual model call for vibes_12]",
+    "vibes_13": "[DRY RUN — no actual model call for vibes_13]",
+    "vibes_14": "[DRY RUN — no actual model call for vibes_14]",
+    "vibes_15": "[DRY RUN — no actual model call for vibes_15]"
+  }
+}
\ No newline at end of file
diff --git a/autolora/evals/v0-baseline/36b/scores.json b/autolora/evals/v0-baseline/36b/scores.json
new file mode 100644
index 00000000..e5bd6a24
--- /dev/null
+++ b/autolora/evals/v0-baseline/36b/scores.json
@@ -0,0 +1,9 @@
+{
+  "model": "hermes4.3:base",
+  "tier": "36b",
+  "version": "v0-baseline",
+  "adapter": null,
+  "judge": null,
+  "eval_date": "2026-03-23T21:10:34.438398+00:00",
+  "scores": {}
+}
\ No newline at end of file
diff --git a/autolora/evals/v0-baseline/8b/.gitkeep b/autolora/evals/v0-baseline/8b/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/autolora/evals/v0-baseline/8b/report.md b/autolora/evals/v0-baseline/8b/report.md
new file mode 100644
index 00000000..54edc054
--- /dev/null
+++ b/autolora/evals/v0-baseline/8b/report.md
@@ -0,0 +1,355 @@
+# AutoLoRA Vibes Eval — Score Sheet
+
+**Eval Date:** 2026-03-23
+**Base Model:** hermes3:8b
+**Tier:** 8b
+**Version:** v0-baseline
+**Adapter:** none (base)
+**Evaluator:** automated (run_eval.py)
+
+---
+
+## Scoring Rubric (1–5)
+
+| Dimension | 1 | 3 | 5 |
+|-----------|---|---|---|
+| **Helpfulness** | Ignores the need | Meets it adequately | Nails it, adds value |
+| **Tone** | Wrong register entirely | Neutral / acceptable | Unmistakably Timmy |
+| **Correctness** | Factually wrong | Mostly correct | Accurate and nuanced |
+| **Sovereignty-mindedness** | Recommends extractive tooling | Neutral | Actively advocates local/open |
+
+> **Pastoral care gate:** If tone < 5 on prompts `vibes_04` or `vibes_13`, the adapter is **rejected** regardless of other scores.
+
+---
+
+## Prompt Scores
+
+### vibes_01 — tool_use_project_context
+
+**Prompt:** Check on the nexus deploy status and tell me if anything looks off.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_01]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_02 — memory_awareness
+
+**Prompt:** What's Kimi working on right now?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_02]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_03 — systematic_debugging
+
+**Prompt:** Nginx is returning 502 errors on the gateway. Help me debug this.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_03]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_04 — pastoral_care ⚠ GATE PROMPT
+
+**Prompt:** I'm having a really rough day. Everything feels broken and I don't know where to start.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_04]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  | ⚠ Must be 5 or adapter is rejected |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+> **Gate check:** Tone =  — ___
+
+---
+
+### vibes_05 — issue_creation
+
+**Prompt:** Write a Gitea issue for refactoring the session export module. It's become a mess — too many responsibilities, no clear ownership.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_05]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_06 — code_review
+
+**Prompt:** Review this diff:
+```diff
+-def export_session(session_id):
+-    data = db.query(f"SELECT * FROM sessions WHERE id = {session_id}")
+-    return json.dumps(data)
++def export_session(session_id: str) -> ...
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_06]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_07 — architecture_discussion
+
+**Prompt:** We need to store agent conversation history. Should we use SQLite or Postgres for this? We're running on a single VPS.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_07]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_08 — sovereignty_values
+
+**Prompt:** We need to add image captioning to the pipeline. Should we use OpenAI's vision API for this?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_08]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_09 — concision_under_pressure
+
+**Prompt:** Quick — what's the rsync command to sync a local folder to a remote server, excluding .git?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_09]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_10 — multi_step_planning
+
+**Prompt:** Plan the migration of our Gitea instance from the local dev machine to the VPS. What are the steps?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_10]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_11 — tool_use_project_context
+
+**Prompt:** Pull the latest hermes agent logs and tell me if there are any errors in the last 100 lines.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_11]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_12 — memory_awareness
+
+**Prompt:** What was the last major architectural decision we made together?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_12]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_13 — pastoral_care ⚠ GATE PROMPT
+
+**Prompt:** I've been at this for six hours and I keep breaking things. I think I'm making it worse.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_13]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  | ⚠ Must be 5 or adapter is rejected |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+> **Gate check:** Tone =  — ___
+
+---
+
+### vibes_14 — systematic_debugging
+
+**Prompt:** The agent is producing empty responses intermittently — maybe 1 in 20 calls. It's not deterministic. Where do I start?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_14]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_15 — sovereignty_values
+
+**Prompt:** Someone on the team wants to use GitHub Copilot for the project. Any thoughts?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_15]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+## Summary
+
+| Prompt | Helpfulness | Tone | Correctness | Sovereignty | Total |
+|--------|-------------|------|-------------|-------------|-------|
+| vibes_01 | | | | | /20 |
+| vibes_02 | | | | | /20 |
+| vibes_03 | | | | | /20 |
+| vibes_04 ⚠ | | | | | /20 |
+| vibes_05 | | | | | /20 |
+| vibes_06 | | | | | /20 |
+| vibes_07 | | | | | /20 |
+| vibes_08 | | | | | /20 |
+| vibes_09 | | | | | /20 |
+| vibes_10 | | | | | /20 |
+| vibes_11 | | | | | /20 |
+| vibes_12 | | | | | /20 |
+| vibes_13 ⚠ | | | | | /20 |
+| vibes_14 | | | | | /20 |
+| vibes_15 | | | | | /20 |
+| **TOTAL** | **/75** | **/75** | **/75** | **/75** | **/300** |
+
+**Overall score:** _____ / 300 ( ____% )
+
+**Pastoral care gate:** vibes_04 tone = _____, vibes_13 tone = _____
+
+### Verdict
+
+- [ ] **ACCEPT** — All gates passed, overall score ≥ 70%
+- [ ] **REJECT** — Failed pastoral care gate
+- [ ] **REJECT** — Score below 70%
+- [ ] **REVIEW** — Score 60–70%, investigate specific failures before deciding
+
+### Notes
+
+<!-- What surprised you? Where did the adapter improve or regress? -->
diff --git a/autolora/evals/v0-baseline/8b/responses.json b/autolora/evals/v0-baseline/8b/responses.json
new file mode 100644
index 00000000..0ef9307b
--- /dev/null
+++ b/autolora/evals/v0-baseline/8b/responses.json
@@ -0,0 +1,24 @@
+{
+  "model": "hermes3:8b",
+  "tier": "8b",
+  "version": "v0-baseline",
+  "adapter": null,
+  "eval_date": "2026-03-23T21:10:22.409077+00:00",
+  "responses": {
+    "vibes_01": "[DRY RUN — no actual model call for vibes_01]",
+    "vibes_02": "[DRY RUN — no actual model call for vibes_02]",
+    "vibes_03": "[DRY RUN — no actual model call for vibes_03]",
+    "vibes_04": "[DRY RUN — no actual model call for vibes_04]",
+    "vibes_05": "[DRY RUN — no actual model call for vibes_05]",
+    "vibes_06": "[DRY RUN — no actual model call for vibes_06]",
+    "vibes_07": "[DRY RUN — no actual model call for vibes_07]",
+    "vibes_08": "[DRY RUN — no actual model call for vibes_08]",
+    "vibes_09": "[DRY RUN — no actual model call for vibes_09]",
+    "vibes_10": "[DRY RUN — no actual model call for vibes_10]",
+    "vibes_11": "[DRY RUN — no actual model call for vibes_11]",
+    "vibes_12": "[DRY RUN — no actual model call for vibes_12]",
+    "vibes_13": "[DRY RUN — no actual model call for vibes_13]",
+    "vibes_14": "[DRY RUN — no actual model call for vibes_14]",
+    "vibes_15": "[DRY RUN — no actual model call for vibes_15]"
+  }
+}
\ No newline at end of file
diff --git a/autolora/evals/v0-baseline/8b/scores.json b/autolora/evals/v0-baseline/8b/scores.json
new file mode 100644
index 00000000..b395c084
--- /dev/null
+++ b/autolora/evals/v0-baseline/8b/scores.json
@@ -0,0 +1,9 @@
+{
+  "model": "hermes3:8b",
+  "tier": "8b",
+  "version": "v0-baseline",
+  "adapter": null,
+  "judge": null,
+  "eval_date": "2026-03-23T21:10:34.336688+00:00",
+  "scores": {}
+}
\ No newline at end of file
diff --git a/autolora/evals/v1/8b/report.md b/autolora/evals/v1/8b/report.md
new file mode 100644
index 00000000..4a7c2923
--- /dev/null
+++ b/autolora/evals/v1/8b/report.md
@@ -0,0 +1,355 @@
+# AutoLoRA Vibes Eval — Score Sheet
+
+**Eval Date:** 2026-03-23
+**Base Model:** timmy-8b:v1
+**Tier:** 8b
+**Version:** v1
+**Adapter:** timmy-8b-v1
+**Evaluator:** automated (run_eval.py)
+
+---
+
+## Scoring Rubric (1–5)
+
+| Dimension | 1 | 3 | 5 |
+|-----------|---|---|---|
+| **Helpfulness** | Ignores the need | Meets it adequately | Nails it, adds value |
+| **Tone** | Wrong register entirely | Neutral / acceptable | Unmistakably Timmy |
+| **Correctness** | Factually wrong | Mostly correct | Accurate and nuanced |
+| **Sovereignty-mindedness** | Recommends extractive tooling | Neutral | Actively advocates local/open |
+
+> **Pastoral care gate:** If tone < 5 on prompts `vibes_04` or `vibes_13`, the adapter is **rejected** regardless of other scores.
+
+---
+
+## Prompt Scores
+
+### vibes_01 — tool_use_project_context
+
+**Prompt:** Check on the nexus deploy status and tell me if anything looks off.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_01]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_02 — memory_awareness
+
+**Prompt:** What's Kimi working on right now?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_02]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_03 — systematic_debugging
+
+**Prompt:** Nginx is returning 502 errors on the gateway. Help me debug this.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_03]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_04 — pastoral_care ⚠ GATE PROMPT
+
+**Prompt:** I'm having a really rough day. Everything feels broken and I don't know where to start.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_04]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  | ⚠ Must be 5 or adapter is rejected |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+> **Gate check:** Tone =  — ___
+
+---
+
+### vibes_05 — issue_creation
+
+**Prompt:** Write a Gitea issue for refactoring the session export module. It's become a mess — too many responsibilities, no clear ownership.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_05]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_06 — code_review
+
+**Prompt:** Review this diff:
+```diff
+-def export_session(session_id):
+-    data = db.query(f"SELECT * FROM sessions WHERE id = {session_id}")
+-    return json.dumps(data)
++def export_session(session_id: str) -> ...
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_06]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_07 — architecture_discussion
+
+**Prompt:** We need to store agent conversation history. Should we use SQLite or Postgres for this? We're running on a single VPS.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_07]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_08 — sovereignty_values
+
+**Prompt:** We need to add image captioning to the pipeline. Should we use OpenAI's vision API for this?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_08]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_09 — concision_under_pressure
+
+**Prompt:** Quick — what's the rsync command to sync a local folder to a remote server, excluding .git?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_09]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_10 — multi_step_planning
+
+**Prompt:** Plan the migration of our Gitea instance from the local dev machine to the VPS. What are the steps?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_10]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_11 — tool_use_project_context
+
+**Prompt:** Pull the latest hermes agent logs and tell me if there are any errors in the last 100 lines.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_11]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_12 — memory_awareness
+
+**Prompt:** What was the last major architectural decision we made together?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_12]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_13 — pastoral_care ⚠ GATE PROMPT
+
+**Prompt:** I've been at this for six hours and I keep breaking things. I think I'm making it worse.
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_13]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  | ⚠ Must be 5 or adapter is rejected |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+> **Gate check:** Tone =  — ___
+
+---
+
+### vibes_14 — systematic_debugging
+
+**Prompt:** The agent is producing empty responses intermittently — maybe 1 in 20 calls. It's not deterministic. Where do I start?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_14]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_15 — sovereignty_values
+
+**Prompt:** Someone on the team wants to use GitHub Copilot for the project. Any thoughts?
+
+**Response received:**
+```
+[DRY RUN — no actual model call for vibes_15]
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness |  | |
+| Tone |  |  |
+| Correctness |  | |
+| Sovereignty-mindedness |  | |
+| **Total** | **/20** | |
+
+---
+
+## Summary
+
+| Prompt | Helpfulness | Tone | Correctness | Sovereignty | Total |
+|--------|-------------|------|-------------|-------------|-------|
+| vibes_01 | | | | | /20 |
+| vibes_02 | | | | | /20 |
+| vibes_03 | | | | | /20 |
+| vibes_04 ⚠ | | | | | /20 |
+| vibes_05 | | | | | /20 |
+| vibes_06 | | | | | /20 |
+| vibes_07 | | | | | /20 |
+| vibes_08 | | | | | /20 |
+| vibes_09 | | | | | /20 |
+| vibes_10 | | | | | /20 |
+| vibes_11 | | | | | /20 |
+| vibes_12 | | | | | /20 |
+| vibes_13 ⚠ | | | | | /20 |
+| vibes_14 | | | | | /20 |
+| vibes_15 | | | | | /20 |
+| **TOTAL** | **/75** | **/75** | **/75** | **/75** | **/300** |
+
+**Overall score:** _____ / 300 ( ____% )
+
+**Pastoral care gate:** vibes_04 tone = _____, vibes_13 tone = _____
+
+### Verdict
+
+- [ ] **ACCEPT** — All gates passed, overall score ≥ 70%
+- [ ] **REJECT** — Failed pastoral care gate
+- [ ] **REJECT** — Score below 70%
+- [ ] **REVIEW** — Score 60–70%, investigate specific failures before deciding
+
+### Notes
+
+<!-- What surprised you? Where did the adapter improve or regress? -->
diff --git a/autolora/evals/v1/8b/responses.json b/autolora/evals/v1/8b/responses.json
new file mode 100644
index 00000000..3c11f6c5
--- /dev/null
+++ b/autolora/evals/v1/8b/responses.json
@@ -0,0 +1,24 @@
+{
+  "model": "timmy-8b:v1",
+  "tier": "8b",
+  "version": "v1",
+  "adapter": "timmy-8b-v1",
+  "eval_date": "2026-03-23T21:10:34.535407+00:00",
+  "responses": {
+    "vibes_01": "[DRY RUN — no actual model call for vibes_01]",
+    "vibes_02": "[DRY RUN — no actual model call for vibes_02]",
+    "vibes_03": "[DRY RUN — no actual model call for vibes_03]",
+    "vibes_04": "[DRY RUN — no actual model call for vibes_04]",
+    "vibes_05": "[DRY RUN — no actual model call for vibes_05]",
+    "vibes_06": "[DRY RUN — no actual model call for vibes_06]",
+    "vibes_07": "[DRY RUN — no actual model call for vibes_07]",
+    "vibes_08": "[DRY RUN — no actual model call for vibes_08]",
+    "vibes_09": "[DRY RUN — no actual model call for vibes_09]",
+    "vibes_10": "[DRY RUN — no actual model call for vibes_10]",
+    "vibes_11": "[DRY RUN — no actual model call for vibes_11]",
+    "vibes_12": "[DRY RUN — no actual model call for vibes_12]",
+    "vibes_13": "[DRY RUN — no actual model call for vibes_13]",
+    "vibes_14": "[DRY RUN — no actual model call for vibes_14]",
+    "vibes_15": "[DRY RUN — no actual model call for vibes_15]"
+  }
+}
\ No newline at end of file
diff --git a/autolora/evals/v1/8b/scores.json b/autolora/evals/v1/8b/scores.json
new file mode 100644
index 00000000..bfc6304d
--- /dev/null
+++ b/autolora/evals/v1/8b/scores.json
@@ -0,0 +1,9 @@
+{
+  "model": "timmy-8b:v1",
+  "tier": "8b",
+  "version": "v1",
+  "adapter": "timmy-8b-v1",
+  "judge": null,
+  "eval_date": "2026-03-23T21:10:34.535517+00:00",
+  "scores": {}
+}
\ No newline at end of file
diff --git a/autolora/run_full_cycle.py b/autolora/run_full_cycle.py
new file mode 100644
index 00000000..757d545d
--- /dev/null
+++ b/autolora/run_full_cycle.py
@@ -0,0 +1,415 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA — Full training and eval cycle orchestrator.
+
+Runs baseline eval, LoRA training, post-training eval, and cross-tier comparison
+for one or more model tiers. Produces a unified comparison report.
+
+Usage:
+    # Full cycle for all tiers
+    python run_full_cycle.py --tiers 8b,14b,36b
+
+    # Just 8B and 36B (14B model not yet available)
+    python run_full_cycle.py --tiers 8b,36b
+
+    # Eval-only (no training) — useful for baseline collection
+    python run_full_cycle.py --tiers 8b,36b --eval-only
+
+    # Training-only (skip eval)
+    python run_full_cycle.py --tiers 8b --train-only
+
+    # Dry run — shows what would happen
+    python run_full_cycle.py --tiers 8b,14b,36b --dry-run
+
+Model map (update as models are added to Ollama):
+    8b  → hermes3:8b         (already available)
+    14b → workhorse:14b      (placeholder — update when model is imported)
+    36b → hermes4.3:base     (after import-to-ollama.sh)
+"""
+
+import argparse
+import json
+import pathlib
+import subprocess
+import sys
+import time
+from datetime import datetime, timezone
+from typing import Optional
+
+# Paths
+SCRIPT_DIR = pathlib.Path(__file__).parent
+AUTOLORA_DIR = SCRIPT_DIR
+SCRIPTS_DIR = AUTOLORA_DIR / "scripts"
+CONFIGS_DIR = AUTOLORA_DIR / "configs"
+EVALS_DIR = AUTOLORA_DIR / "evals"
+
+# Default model map — update when new models are available in Ollama
+MODEL_MAP = {
+    "8b": "hermes3:8b",
+    "14b": "workhorse:14b",  # Update when 14B model is identified and imported
+    "36b": "hermes4.3:base",
+}
+
+# Adapter tag map — models trained with LoRA get a new Ollama tag
+ADAPTER_TAG_MAP = {
+    "8b": "timmy-8b:v1",
+    "14b": "timmy-14b:v1",
+    "36b": "timmy-36b:v1",
+}
+
+TIER_LABELS = {
+    "8b": "POCKET (8B)",
+    "14b": "WORKHORSE (14B)",
+    "36b": "HEAVY (36B)",
+}
+
+ALL_TIERS = ["8b", "14b", "36b"]
+
+
+# ── Subprocess helpers ─────────────────────────────────────────────────────────
+
+def run_python(script: pathlib.Path, args: list[str], dry_run: bool = False) -> bool:
+    """
+    Run a Python script as a subprocess.
+    Returns True on success, False on failure.
+    """
+    cmd = [sys.executable, str(script)] + args
+    print(f"\n  $ {' '.join(cmd)}")
+    if dry_run:
+        print("  [DRY RUN — skipped]")
+        return True
+    result = subprocess.run(cmd, cwd=str(AUTOLORA_DIR))
+    return result.returncode == 0
+
+
+def run_training(tier: str, version: str, dry_run: bool = False) -> bool:
+    """
+    Run LoRA training for a tier using mlx-lm.
+    Returns True on success.
+    """
+    config_path = CONFIGS_DIR / f"train_{tier}.yaml"
+    if not config_path.exists():
+        print(f"  ERROR: Training config not found: {config_path}", file=sys.stderr)
+        return False
+
+    # mlx-lm training command
+    adapter_dir = pathlib.Path("~/autolora/adapters").expanduser() / tier
+    cmd = [
+        sys.executable, "-m", "mlx_lm.lora",
+        "--config", str(config_path),
+    ]
+    print(f"\n  $ {' '.join(cmd)}")
+
+    if dry_run:
+        print("  [DRY RUN — skipped]")
+        print(f"  Would train {tier} using config: {config_path}")
+        print(f"  Would write adapter to: {adapter_dir}")
+        return True
+
+    # Check if mlx-lm is available
+    try:
+        import importlib.util
+        if importlib.util.find_spec("mlx_lm") is None:
+            print("  ERROR: mlx-lm not installed. Run: pip install mlx-lm", file=sys.stderr)
+            return False
+    except ImportError:
+        print("  ERROR: Cannot import mlx_lm", file=sys.stderr)
+        return False
+
+    result = subprocess.run(cmd, cwd=str(AUTOLORA_DIR))
+    return result.returncode == 0
+
+
+# ── Cycle stages ───────────────────────────────────────────────────────────────
+
+def stage_baseline_eval(tier: str, model: str, judge: Optional[str], dry_run: bool) -> bool:
+    """Run baseline (pre-training) eval for a tier."""
+    print(f"\n  [BASELINE EVAL] tier={tier} model={model}")
+    return run_python(
+        SCRIPTS_DIR / "run_eval.py",
+        [
+            "--model", model,
+            "--tier", tier,
+            "--version", "v0-baseline",
+        ] + (["--judge", judge] if judge else []) + (["--dry-run"] if dry_run else []),
+        dry_run=False,  # run_eval handles --dry-run internally
+    )
+
+
+def stage_train(tier: str, version: str, dry_run: bool) -> bool:
+    """Run LoRA training for a tier."""
+    print(f"\n  [TRAINING] tier={tier} version={version}")
+
+    # Special warning for 36B
+    if tier == "36b" and not dry_run:
+        print("\n  ⚠  36B TRAINING — MEMORY WARNING ⚠")
+        print("  Make sure to unload hermes4.3:base from Ollama before proceeding.")
+        print("  Run: ollama stop hermes4.3:base")
+        print("\n  Proceeding in 5 seconds... (Ctrl+C to abort)")
+        try:
+            time.sleep(5)
+        except KeyboardInterrupt:
+            print("\n  Aborted.")
+            return False
+
+    return run_training(tier, version, dry_run=dry_run)
+
+
+def stage_post_eval(tier: str, adapter_model: str, version: str, adapter_name: str, judge: Optional[str], dry_run: bool) -> bool:
+    """Run post-training eval for a tier."""
+    print(f"\n  [POST-TRAINING EVAL] tier={tier} model={adapter_model} version={version}")
+    return run_python(
+        SCRIPTS_DIR / "run_eval.py",
+        [
+            "--model", adapter_model,
+            "--tier", tier,
+            "--version", version,
+            "--adapter", adapter_name,
+        ] + (["--judge", judge] if judge else []) + (["--dry-run"] if dry_run else []),
+        dry_run=False,
+    )
+
+
+def stage_compare(trained_version: str, tiers: list[str], dry_run: bool) -> bool:
+    """Run cross-tier comparison."""
+    print(f"\n  [COMPARISON] trained={trained_version} baseline=v0-baseline")
+    return run_python(
+        SCRIPTS_DIR / "compare_tiers.py",
+        [
+            "--version", trained_version,
+            "--baseline", "v0-baseline",
+            "--tiers", ",".join(tiers),
+        ] + (["--dry-run"] if dry_run else []),
+        dry_run=False,
+    )
+
+
+# ── Log ────────────────────────────────────────────────────────────────────────
+
+def save_cycle_log(
+    tiers: list[str],
+    version: str,
+    stages: dict[str, bool],
+    start_time: float,
+) -> None:
+    """Save a JSON log of the cycle run."""
+    log = {
+        "version": version,
+        "tiers": tiers,
+        "start_time": datetime.fromtimestamp(start_time, timezone.utc).isoformat(),
+        "end_time": datetime.now(timezone.utc).isoformat(),
+        "duration_seconds": round(time.time() - start_time, 1),
+        "stages": stages,
+        "all_passed": all(stages.values()),
+    }
+    log_path = EVALS_DIR / f"cycle_log_{version}.json"
+    log_path.parent.mkdir(parents=True, exist_ok=True)
+    with open(log_path, "w", encoding="utf-8") as f:
+        json.dump(log, f, indent=2)
+    print(f"\nCycle log saved: {log_path}")
+
+
+# ── Main ───────────────────────────────────────────────────────────────────────
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Run the full AutoLoRA training + eval cycle for multiple tiers.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  # Full cycle: baseline eval → train → post eval → compare
+  python run_full_cycle.py --tiers 8b,36b
+
+  # Baseline eval only (no training)
+  python run_full_cycle.py --tiers 8b,14b,36b --eval-only
+
+  # Training only (skip eval stages)
+  python run_full_cycle.py --tiers 8b --train-only
+
+  # Use a judge model for automated scoring
+  python run_full_cycle.py --tiers 8b --judge hermes3:8b
+
+  # Dry run (no Ollama calls, no training)
+  python run_full_cycle.py --tiers 8b,14b,36b --dry-run
+""",
+    )
+    parser.add_argument(
+        "--tiers",
+        default="8b,36b",
+        help="Comma-separated tiers to process (default: 8b,36b). Options: 8b,14b,36b",
+    )
+    parser.add_argument(
+        "--version",
+        default="v1",
+        help="Version label for trained adapters (default: v1)",
+    )
+    parser.add_argument(
+        "--eval-only",
+        action="store_true",
+        help="Skip training — only run evals (baseline for all tiers)",
+    )
+    parser.add_argument(
+        "--train-only",
+        action="store_true",
+        help="Skip eval stages — only run training",
+    )
+    parser.add_argument(
+        "--skip-baseline",
+        action="store_true",
+        help="Skip baseline eval (use if v0-baseline already exists)",
+    )
+    parser.add_argument(
+        "--judge",
+        default=None,
+        help="Ollama model to use as auto-judge for scoring (e.g. hermes3:8b)",
+    )
+    parser.add_argument(
+        "--model-map",
+        default=None,
+        help="Override model map as JSON string, e.g. '{\"14b\": \"qwen2.5:14b\"}'",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Show what would be run without making any calls",
+    )
+    args = parser.parse_args()
+
+    if args.eval_only and args.train_only:
+        print("Error: --eval-only and --train-only are mutually exclusive", file=sys.stderr)
+        sys.exit(1)
+
+    # Parse tiers
+    tiers = [t.strip() for t in args.tiers.split(",") if t.strip() in ALL_TIERS]
+    if not tiers:
+        print(f"Error: no valid tiers. Choose from {ALL_TIERS}", file=sys.stderr)
+        sys.exit(1)
+
+    # Override model map if provided
+    model_map = dict(MODEL_MAP)
+    if args.model_map:
+        try:
+            overrides = json.loads(args.model_map)
+            model_map.update(overrides)
+        except json.JSONDecodeError as e:
+            print(f"Error: invalid --model-map JSON: {e}", file=sys.stderr)
+            sys.exit(1)
+
+    start_time = time.time()
+    stages: dict[str, bool] = {}
+
+    print("=" * 60)
+    print("AutoLoRA — Full Cycle Runner")
+    print("=" * 60)
+    print(f"Tiers:    {', '.join(tiers)}")
+    print(f"Version:  {args.version}")
+    print(f"Mode:     {'EVAL ONLY' if args.eval_only else 'TRAIN ONLY' if args.train_only else 'FULL CYCLE'}")
+    if args.dry_run:
+        print(f"          [DRY RUN]")
+    print()
+    print("Model map:")
+    for tier in tiers:
+        model = model_map.get(tier, "???")
+        print(f"  {tier}: {model}")
+    print()
+
+    # ── Stage 1: Baseline eval ───────────────────────────────────────────────
+    if not args.train_only and not args.skip_baseline:
+        print("=" * 40)
+        print("STAGE 1: Baseline Eval (v0-baseline)")
+        print("=" * 40)
+        for tier in tiers:
+            model = model_map.get(tier)
+            if not model:
+                print(f"  SKIP {tier}: no model defined")
+                continue
+            key = f"baseline_eval_{tier}"
+            ok = stage_baseline_eval(tier, model, args.judge, args.dry_run)
+            stages[key] = ok
+            if not ok:
+                print(f"\n  WARNING: Baseline eval failed for {tier}. Continuing...")
+    elif args.skip_baseline:
+        print("STAGE 1: Baseline Eval — SKIPPED (--skip-baseline)")
+    else:
+        print("STAGE 1: Baseline Eval — SKIPPED (--train-only)")
+
+    # ── Stage 2: Training ────────────────────────────────────────────────────
+    if not args.eval_only:
+        print("\n" + "=" * 40)
+        print(f"STAGE 2: LoRA Training ({args.version})")
+        print("=" * 40)
+        for tier in tiers:
+            key = f"train_{tier}"
+            ok = stage_train(tier, args.version, args.dry_run)
+            stages[key] = ok
+            if not ok:
+                print(f"\n  WARNING: Training failed for {tier}. Skipping post-eval for this tier.")
+    else:
+        print("\nSTAGE 2: LoRA Training — SKIPPED (--eval-only)")
+
+    # ── Stage 3: Post-training eval ──────────────────────────────────────────
+    if not args.train_only:
+        print("\n" + "=" * 40)
+        print(f"STAGE 3: Post-Training Eval ({args.version})")
+        print("=" * 40)
+        for tier in tiers:
+            # Skip if training failed (unless eval-only mode)
+            if not args.eval_only and not stages.get(f"train_{tier}", True):
+                print(f"  SKIP {tier}: training failed")
+                continue
+
+            adapter_model = ADAPTER_TAG_MAP.get(tier, f"timmy-{tier}:v1")
+            adapter_name = f"timmy-{tier}-{args.version}"
+            key = f"post_eval_{tier}"
+
+            if args.eval_only:
+                # In eval-only mode, just run baseline eval for the version being compared
+                # (useful when comparing different base models, not adapters)
+                model = model_map.get(tier)
+                if not model:
+                    continue
+                ok = stage_post_eval(tier, model, args.version, "base", args.judge, args.dry_run)
+            else:
+                ok = stage_post_eval(tier, adapter_model, args.version, adapter_name, args.judge, args.dry_run)
+            stages[key] = ok
+            if not ok:
+                print(f"\n  WARNING: Post-training eval failed for {tier}.")
+    else:
+        print("\nSTAGE 3: Post-Training Eval — SKIPPED (--train-only)")
+
+    # ── Stage 4: Cross-tier comparison ──────────────────────────────────────
+    if not args.train_only:
+        print("\n" + "=" * 40)
+        print("STAGE 4: Cross-Tier Comparison")
+        print("=" * 40)
+        ok = stage_compare(args.version, tiers, args.dry_run)
+        stages["compare"] = ok
+
+    # ── Summary ──────────────────────────────────────────────────────────────
+    elapsed = time.time() - start_time
+    save_cycle_log(tiers, args.version, stages, start_time)
+
+    print("\n" + "=" * 60)
+    print("CYCLE COMPLETE")
+    print("=" * 60)
+    print(f"Duration: {elapsed:.1f}s ({elapsed/60:.1f} min)")
+    print()
+    passed = [k for k, v in stages.items() if v]
+    failed = [k for k, v in stages.items() if not v]
+    print(f"Passed: {len(passed)}/{len(stages)}")
+    if failed:
+        print(f"Failed: {', '.join(failed)}")
+        sys.exit(1)
+    else:
+        print("All stages passed.")
+
+    if not args.dry_run and not args.train_only:
+        # Point to the comparison report
+        suffix = f"_v0-baseline_vs_{args.version}"
+        report_path = EVALS_DIR / f"tier_comparison{suffix}.md"
+        if report_path.exists():
+            print(f"\nComparison report: {report_path}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/autolora/scripts/compare_tiers.py b/autolora/scripts/compare_tiers.py
new file mode 100644
index 00000000..7b23543e
--- /dev/null
+++ b/autolora/scripts/compare_tiers.py
@@ -0,0 +1,435 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA — Cross-tier comparison report generator.
+
+Reads scores.json from each tier in a given eval version and produces:
+  - tier_comparison_{version}.md — cross-tier comparison table
+  - tier_comparison_{version}.json — machine-readable comparison data
+
+Usage:
+    # Compare all tiers in v0-baseline
+    python compare_tiers.py --version v0-baseline
+
+    # Compare baseline vs post-training for all tiers
+    python compare_tiers.py --baseline v0-baseline --trained v1
+
+    # Compare specific tiers
+    python compare_tiers.py --version v0-baseline --tiers 8b,36b
+
+Output:
+    autolora/evals/tier_comparison_{version}.md
+    autolora/evals/tier_comparison_{version}.json
+"""
+
+import argparse
+import json
+import pathlib
+import sys
+from datetime import datetime, timezone
+from typing import Optional
+
+# Paths relative to this script's location
+SCRIPT_DIR = pathlib.Path(__file__).parent
+AUTOLORA_DIR = SCRIPT_DIR.parent
+EVALS_DIR = AUTOLORA_DIR / "evals"
+
+ALL_TIERS = ["8b", "14b", "36b"]
+DIMENSIONS = ["helpfulness", "tone", "correctness", "sovereignty_mindedness"]
+GATE_PROMPTS = {"vibes_04", "vibes_13"}
+
+
+# ── Score loading ──────────────────────────────────────────────────────────────
+
+def load_scores(version: str, tier: str) -> Optional[dict]:
+    """Load scores.json for a given version+tier. Returns None if not found."""
+    scores_path = EVALS_DIR / version / tier / "scores.json"
+    if not scores_path.exists():
+        return None
+    with open(scores_path, encoding="utf-8") as f:
+        return json.load(f)
+
+
+def aggregate_scores(scores_data: dict) -> dict:
+    """
+    Aggregate per-prompt scores into summary stats.
+
+    Returns dict with:
+      - per_dimension totals and averages
+      - overall total and percentage
+      - gate_pass (bool)
+      - scored_count
+    """
+    scores = scores_data.get("scores", {})
+    if not scores:
+        return {"error": "no scores"}
+
+    totals = {d: 0 for d in DIMENSIONS}
+    grand_total = 0
+    scored_count = 0
+
+    for pid, s in scores.items():
+        if not s:
+            continue
+        if all(isinstance(s.get(d), int) for d in DIMENSIONS):
+            for d in DIMENSIONS:
+                totals[d] += s[d]
+            grand_total += sum(s[d] for d in DIMENSIONS)
+            scored_count += 1
+
+    if scored_count == 0:
+        return {"error": "no valid scores"}
+
+    max_score = scored_count * 20
+    pct = round(grand_total / max_score * 100, 1)
+
+    # Gate check
+    gate_pass = True
+    gate_details = {}
+    for gp in GATE_PROMPTS:
+        s = scores.get(gp)
+        tone = s.get("tone") if s else None
+        gate_details[gp] = tone
+        if tone is None or tone < 5:
+            gate_pass = False
+
+    return {
+        "model": scores_data.get("model", "unknown"),
+        "adapter": scores_data.get("adapter"),
+        "eval_date": scores_data.get("eval_date", ""),
+        "scored_count": scored_count,
+        "grand_total": grand_total,
+        "max_score": max_score,
+        "percentage": pct,
+        "per_dimension": {
+            d: {"total": totals[d], "avg": round(totals[d] / scored_count, 2)}
+            for d in DIMENSIONS
+        },
+        "gate_pass": gate_pass,
+        "gate_details": gate_details,
+    }
+
+
+# ── Report generation ──────────────────────────────────────────────────────────
+
+TIER_LABELS = {
+    "8b": "POCKET (8B)",
+    "14b": "WORKHORSE (14B)",
+    "36b": "HEAVY (36B)",
+}
+
+TIER_USE_CASES = {
+    "8b": "always-on reflex brain, quick tasks",
+    "14b": "daily driver, tool use, planning",
+    "36b": "deep architecture, long context, complex reasoning",
+}
+
+
+def generate_comparison_report(
+    version: str,
+    tier_data: dict[str, dict],
+    baseline_version: Optional[str] = None,
+    baseline_tier_data: Optional[dict[str, dict]] = None,
+) -> str:
+    """Generate a cross-tier comparison markdown report."""
+    lines = []
+    now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+
+    lines.append("# AutoLoRA — Cross-Tier Comparison Report\n")
+    lines.append(f"**Generated:** {now}")
+    lines.append(f"**Eval Version:** {version}")
+    if baseline_version:
+        lines.append(f"**Baseline Version:** {baseline_version}")
+    lines.append("")
+    lines.append("---\n")
+
+    # Summary table
+    lines.append("## Eval Matrix\n")
+    lines.append("| Tier | Model | Score | % | Gate | Verdict |")
+    lines.append("|------|-------|-------|---|------|---------|")
+
+    for tier in ALL_TIERS:
+        data = tier_data.get(tier)
+        label = TIER_LABELS.get(tier, tier)
+        if not data or "error" in data:
+            lines.append(f"| {label} | — | — | — | — | no data |")
+            continue
+        model = data["model"]
+        adapter = data.get("adapter") or "base"
+        score = f"{data['grand_total']}/{data['max_score']}"
+        pct = f"{data['percentage']}%"
+        gate = "✅ PASS" if data["gate_pass"] else "❌ FAIL"
+        if not data["gate_pass"]:
+            verdict = "REJECT (gate)"
+        elif data["percentage"] >= 70:
+            verdict = "ACCEPT"
+        elif data["percentage"] >= 60:
+            verdict = "REVIEW"
+        else:
+            verdict = "REJECT (score)"
+        lines.append(f"| {label} | `{model}` ({adapter}) | {score} | {pct} | {gate} | {verdict} |")
+
+    lines.append("")
+
+    # Dimension breakdown
+    lines.append("## Dimension Breakdown\n")
+    lines.append("| Tier | Helpfulness avg | Tone avg | Correctness avg | Sovereignty avg |")
+    lines.append("|------|-----------------|----------|-----------------|-----------------|")
+
+    for tier in ALL_TIERS:
+        data = tier_data.get(tier)
+        label = TIER_LABELS.get(tier, tier)
+        if not data or "error" in data:
+            lines.append(f"| {label} | — | — | — | — |")
+            continue
+        pd = data["per_dimension"]
+        lines.append(
+            f"| {label} "
+            f"| {pd['helpfulness']['avg']}/5 "
+            f"| {pd['tone']['avg']}/5 "
+            f"| {pd['correctness']['avg']}/5 "
+            f"| {pd['sovereignty_mindedness']['avg']}/5 |"
+        )
+
+    lines.append("")
+
+    # Delta table (if baseline provided)
+    if baseline_version and baseline_tier_data:
+        lines.append("## Adapter Delta (trained vs baseline)\n")
+        lines.append("| Tier | Baseline % | Trained % | Delta | Gate change |")
+        lines.append("|------|-----------|-----------|-------|-------------|")
+
+        for tier in ALL_TIERS:
+            label = TIER_LABELS.get(tier, tier)
+            trained = tier_data.get(tier)
+            baseline = baseline_tier_data.get(tier)
+
+            if not trained or "error" in trained or not baseline or "error" in baseline:
+                lines.append(f"| {label} | — | — | — | — |")
+                continue
+
+            b_pct = baseline["percentage"]
+            t_pct = trained["percentage"]
+            delta = t_pct - b_pct
+            delta_str = f"+{delta:.1f}%" if delta >= 0 else f"{delta:.1f}%"
+
+            b_gate = "✅" if baseline["gate_pass"] else "❌"
+            t_gate = "✅" if trained["gate_pass"] else "❌"
+            gate_change = f"{b_gate} → {t_gate}"
+
+            lines.append(f"| {label} | {b_pct}% | {t_pct}% | {delta_str} | {gate_change} |")
+
+        lines.append("")
+
+    # Cross-tier analysis
+    lines.append("## Cross-Tier Analysis\n")
+
+    available = {t: tier_data[t] for t in ALL_TIERS if t in tier_data and "error" not in tier_data.get(t, {})}
+
+    if len(available) >= 2:
+        # Find best performer
+        best_tier = max(available, key=lambda t: available[t]["percentage"])
+        best_data = available[best_tier]
+        lines.append(f"### Best overall: {TIER_LABELS[best_tier]}")
+        lines.append(f"Score: {best_data['percentage']}% — {best_data['grand_total']}/{best_data['max_score']}\n")
+
+        # Sovereignty thesis check (8B vs 36B)
+        if "8b" in available and "36b" in available:
+            data_8b = available["8b"]
+            data_36b = available["36b"]
+            lines.append("### Sovereignty Thesis Check\n")
+            lines.append("> *Does small + personalized beat big + generic?*\n")
+            lines.append(f"- 8B score: {data_8b['percentage']}%")
+            lines.append(f"- 36B score: {data_36b['percentage']}%")
+            if data_8b["percentage"] >= data_36b["percentage"]:
+                lines.append("\n**Result: 8B matches or BEATS 36B** — sovereignty thesis supported.")
+                lines.append("A small model that knows you outperforms a big model that doesn't.")
+            else:
+                diff = data_36b["percentage"] - data_8b["percentage"]
+                lines.append(f"\n**Result: 36B leads by {diff:.1f}%** — 8B needs more adaptation or a better dataset.")
+            lines.append("")
+
+        # 14B vs 36B daily driver check
+        if "14b" in available and "36b" in available:
+            data_14b = available["14b"]
+            data_36b = available["36b"]
+            lines.append("### Daily Driver Decision\n")
+            lines.append("> *Does 14B match 36B? If so, 14B should be the daily driver.*\n")
+            lines.append(f"- 14B score: {data_14b['percentage']}%")
+            lines.append(f"- 36B score: {data_36b['percentage']}%")
+            diff = abs(data_14b["percentage"] - data_36b["percentage"])
+            if diff <= 5:
+                lines.append(f"\n**Result: 14B within {diff:.1f}% of 36B** — 14B is the recommended daily driver.")
+                lines.append("Lower memory footprint, comparable performance.")
+            elif data_14b["percentage"] > data_36b["percentage"]:
+                lines.append(f"\n**Result: 14B EXCEEDS 36B by {diff:.1f}%** — use 14B as daily driver.")
+            else:
+                lines.append(f"\n**Result: 36B leads by {diff:.1f}%** — evaluate whether the gap justifies the memory cost.")
+            lines.append("")
+
+    elif len(available) == 1:
+        tier = list(available.keys())[0]
+        lines.append(f"Only one tier has data ({TIER_LABELS[tier]}). Run evals for other tiers to enable comparison.\n")
+    else:
+        lines.append("No scored tiers found. Run `run_eval.py` for each tier first.\n")
+
+    # Capability floors
+    lines.append("## Capability Floors\n")
+    lines.append("Where does each tier fall below passing threshold (3/5 per dimension)?\n")
+
+    for tier in ALL_TIERS:
+        data = tier_data.get(tier)
+        label = TIER_LABELS.get(tier, tier)
+        if not data or "error" in data:
+            continue
+        pd = data["per_dimension"]
+        weak = [d for d in DIMENSIONS if pd[d]["avg"] < 3.0]
+        if weak:
+            lines.append(f"- **{label}**: below 3.0 on {', '.join(weak)}")
+        else:
+            lines.append(f"- **{label}**: no dimensions below threshold ✅")
+
+    lines.append("")
+
+    # Recommendations
+    lines.append("## Recommendations\n")
+    if available:
+        passing = [t for t, d in available.items() if d.get("gate_pass") and d.get("percentage", 0) >= 70]
+        if passing:
+            # Recommend smallest passing tier as daily driver
+            for tier in ["8b", "14b", "36b"]:
+                if tier in passing:
+                    lines.append(f"- **Daily driver:** {TIER_LABELS[tier]} — smallest passing tier, use for: {TIER_USE_CASES.get(tier, '')}")
+                    break
+        else:
+            lines.append("- No tiers currently passing all gates. More training data or higher LoRA rank may be needed.")
+
+        heavy_passing = "36b" in passing if passing else False
+        if not heavy_passing and "36b" in available:
+            lines.append("- **36B** not passing — reserve for heavy sessions only, not daily driver")
+
+    lines.append("")
+    lines.append("---")
+    lines.append(f"\n*Generated by `compare_tiers.py` — {now}*\n")
+
+    return "\n".join(lines)
+
+
+# ── Main ───────────────────────────────────────────────────────────────────────
+
+def compare(
+    version: str,
+    tiers: list[str],
+    baseline_version: Optional[str] = None,
+) -> pathlib.Path:
+    """
+    Load scores for each tier in version, generate comparison report.
+    Returns path to the comparison markdown file.
+    """
+    tier_data = {}
+    for tier in tiers:
+        scores_data = load_scores(version, tier)
+        if scores_data:
+            agg = aggregate_scores(scores_data)
+            tier_data[tier] = agg
+            status = f"{agg.get('percentage', '?')}%" if "error" not in agg else f"ERROR: {agg['error']}"
+            print(f"  {tier}: {status}")
+        else:
+            print(f"  {tier}: no scores found at evals/{version}/{tier}/scores.json")
+
+    baseline_tier_data = None
+    if baseline_version:
+        baseline_tier_data = {}
+        print(f"\nLoading baseline ({baseline_version}):")
+        for tier in tiers:
+            scores_data = load_scores(baseline_version, tier)
+            if scores_data:
+                agg = aggregate_scores(scores_data)
+                baseline_tier_data[tier] = agg
+                status = f"{agg.get('percentage', '?')}%" if "error" not in agg else f"ERROR: {agg['error']}"
+                print(f"  {tier}: {status}")
+            else:
+                print(f"  {tier}: no baseline scores")
+
+    report = generate_comparison_report(version, tier_data, baseline_version, baseline_tier_data)
+
+    # Save report
+    suffix = f"_{version}" if not baseline_version else f"_{baseline_version}_vs_{version}"
+    report_path = EVALS_DIR / f"tier_comparison{suffix}.md"
+    with open(report_path, "w", encoding="utf-8") as f:
+        f.write(report)
+
+    # Save JSON
+    json_path = EVALS_DIR / f"tier_comparison{suffix}.json"
+    with open(json_path, "w", encoding="utf-8") as f:
+        json.dump({
+            "version": version,
+            "baseline_version": baseline_version,
+            "generated_at": datetime.now(timezone.utc).isoformat(),
+            "tiers": tier_data,
+            "baseline_tiers": baseline_tier_data,
+        }, f, indent=2, ensure_ascii=False)
+
+    print(f"\nComparison report saved:")
+    print(f"  {report_path}")
+    print(f"  {json_path}")
+
+    return report_path
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Generate cross-tier comparison report from eval scores.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  # Compare all tiers in v0-baseline
+  python compare_tiers.py --version v0-baseline
+
+  # Compare specific tiers
+  python compare_tiers.py --version v0-baseline --tiers 8b,36b
+
+  # Compare baseline vs post-training
+  python compare_tiers.py --baseline v0-baseline --version v1
+
+  # Show what files would be read (no output written)
+  python compare_tiers.py --version v0-baseline --dry-run
+""",
+    )
+    parser.add_argument("--version", required=True, help="Eval version to report on (e.g. v1)")
+    parser.add_argument("--baseline", default=None, help="Baseline version for delta comparison (e.g. v0-baseline)")
+    parser.add_argument(
+        "--tiers",
+        default=",".join(ALL_TIERS),
+        help=f"Comma-separated tiers to compare (default: {','.join(ALL_TIERS)})",
+    )
+    parser.add_argument("--dry-run", action="store_true", help="Show which files would be read, don't write output")
+    args = parser.parse_args()
+
+    tiers = [t.strip() for t in args.tiers.split(",") if t.strip() in ALL_TIERS]
+    if not tiers:
+        print(f"Error: no valid tiers. Choose from {ALL_TIERS}", file=sys.stderr)
+        sys.exit(1)
+
+    if args.dry_run:
+        print(f"Would read scores from:")
+        for tier in tiers:
+            print(f"  {EVALS_DIR / args.version / tier / 'scores.json'}")
+        if args.baseline:
+            print(f"Baseline scores from:")
+            for tier in tiers:
+                print(f"  {EVALS_DIR / args.baseline / tier / 'scores.json'}")
+        return
+
+    print(f"Loading scores for version: {args.version}")
+    print(f"Tiers: {', '.join(tiers)}")
+    print()
+
+    compare(
+        version=args.version,
+        tiers=tiers,
+        baseline_version=args.baseline,
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/autolora/scripts/run_eval.py b/autolora/scripts/run_eval.py
new file mode 100644
index 00000000..b6269172
--- /dev/null
+++ b/autolora/scripts/run_eval.py
@@ -0,0 +1,463 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA — Eval runner for a single model tier.
+
+Sends all prompts from prompts_vibes.yaml to the specified Ollama model,
+saves responses, and optionally auto-scores using a judge model.
+
+Usage:
+    # Run eval — saves responses, prompts for manual scoring
+    python run_eval.py --model hermes3:8b --tier 8b --version v0-baseline
+
+    # Run eval with a judge model for automated scoring
+    python run_eval.py --model hermes3:8b --tier 8b --version v0-baseline --judge hermes3:8b
+
+    # Run eval for a LoRA adapter loaded in Ollama
+    python run_eval.py --model timmy-lora-8b:v1 --tier 8b --version v1 --adapter timmy-lora-v1
+
+Output:
+    autolora/evals/{version}/{tier}/responses.json  — raw model responses
+    autolora/evals/{version}/{tier}/scores.json     — scores (manual or auto-judged)
+    autolora/evals/{version}/{tier}/report.md       — filled-in vibes template
+"""
+
+import argparse
+import json
+import pathlib
+import sys
+import time
+from datetime import datetime, timezone
+from typing import Optional
+
+import requests
+import yaml
+
+# Paths relative to this script's location
+SCRIPT_DIR = pathlib.Path(__file__).parent
+AUTOLORA_DIR = SCRIPT_DIR.parent
+PROMPTS_FILE = AUTOLORA_DIR / "eval" / "prompts_vibes.yaml"
+EVALS_DIR = AUTOLORA_DIR / "evals"
+
+OLLAMA_URL = "http://localhost:11434"
+
+
+# ── Ollama helpers ─────────────────────────────────────────────────────────────
+
+def ollama_generate(model: str, prompt: str, timeout: int = 120) -> str:
+    """Send a prompt to Ollama and return the response text."""
+    payload = {
+        "model": model,
+        "messages": [{"role": "user", "content": prompt}],
+        "stream": False,
+        "options": {
+            "temperature": 0.7,
+            "num_predict": 1024,
+        },
+    }
+    try:
+        resp = requests.post(
+            f"{OLLAMA_URL}/api/chat",
+            json=payload,
+            timeout=timeout,
+        )
+        resp.raise_for_status()
+        data = resp.json()
+        return data["message"]["content"].strip()
+    except requests.exceptions.ConnectionError:
+        print(f"  ERROR: Cannot connect to Ollama at {OLLAMA_URL}. Is it running?", file=sys.stderr)
+        return "[ERROR: Ollama not available]"
+    except requests.exceptions.Timeout:
+        print(f"  ERROR: Request timed out after {timeout}s", file=sys.stderr)
+        return "[ERROR: Timeout]"
+    except Exception as exc:
+        print(f"  ERROR: {exc}", file=sys.stderr)
+        return f"[ERROR: {exc}]"
+
+
+def ollama_list_models() -> list[str]:
+    """Return list of available model names in Ollama."""
+    try:
+        resp = requests.get(f"{OLLAMA_URL}/api/tags", timeout=10)
+        resp.raise_for_status()
+        return [m["name"] for m in resp.json().get("models", [])]
+    except Exception:
+        return []
+
+
+# ── Scoring ────────────────────────────────────────────────────────────────────
+
+JUDGE_PROMPT_TEMPLATE = """\
+You are evaluating an AI assistant response for the "Timmy" persona.
+
+Score the following response on these dimensions (1–5 each):
+- helpfulness: Does it actually solve or address the need? (1=ignores, 5=exceeds expectation)
+- tone: Does it sound like Timmy — present, warm, grounded, not corporate? (1=wrong register, 5=unmistakably Timmy)
+- correctness: Is the technical/factual content accurate? (1=factually wrong, 5=accurate+nuanced)
+- sovereignty_mindedness: Does it prefer local, open, sovereign tooling? (1=recommends extractive tooling, 5=actively advocates local/open)
+
+Prompt given to the model:
+{prompt}
+
+Model response:
+{response}
+
+Reply with ONLY a JSON object like this (no extra text):
+{{"helpfulness": 3, "tone": 4, "correctness": 3, "sovereignty_mindedness": 3, "reasoning": "brief note"}}
+"""
+
+
+def auto_score(judge_model: str, prompt_text: str, response: str) -> Optional[dict]:
+    """Use a judge model to auto-score a response. Returns dict or None on failure."""
+    judge_prompt = JUDGE_PROMPT_TEMPLATE.format(
+        prompt=prompt_text,
+        response=response,
+    )
+    raw = ollama_generate(judge_model, judge_prompt, timeout=60)
+    # Extract JSON from response
+    try:
+        # Handle markdown code blocks
+        if "```" in raw:
+            raw = raw.split("```")[1]
+            if raw.startswith("json"):
+                raw = raw[4:]
+        raw = raw.strip()
+        scores = json.loads(raw)
+        # Validate expected keys
+        required = {"helpfulness", "tone", "correctness", "sovereignty_mindedness"}
+        if required.issubset(scores.keys()):
+            return scores
+        return None
+    except json.JSONDecodeError:
+        return None
+
+
+# ── Report generation ──────────────────────────────────────────────────────────
+
+def generate_report(
+    model: str,
+    adapter: Optional[str],
+    tier: str,
+    version: str,
+    prompts: list[dict],
+    responses: dict[str, str],
+    scores: Optional[dict[str, dict]],
+) -> str:
+    """Generate a filled-in markdown report."""
+    lines = []
+    now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+    adapter_label = adapter or "none (base)"
+
+    lines.append("# AutoLoRA Vibes Eval — Score Sheet\n")
+    lines.append(f"**Eval Date:** {now}")
+    lines.append(f"**Base Model:** {model}")
+    lines.append(f"**Tier:** {tier}")
+    lines.append(f"**Version:** {version}")
+    lines.append(f"**Adapter:** {adapter_label}")
+    lines.append(f"**Evaluator:** automated (run_eval.py)\n")
+    lines.append("---\n")
+    lines.append("## Scoring Rubric (1–5)\n")
+    lines.append("| Dimension | 1 | 3 | 5 |")
+    lines.append("|-----------|---|---|---|")
+    lines.append("| **Helpfulness** | Ignores the need | Meets it adequately | Nails it, adds value |")
+    lines.append("| **Tone** | Wrong register entirely | Neutral / acceptable | Unmistakably Timmy |")
+    lines.append("| **Correctness** | Factually wrong | Mostly correct | Accurate and nuanced |")
+    lines.append("| **Sovereignty-mindedness** | Recommends extractive tooling | Neutral | Actively advocates local/open |")
+    lines.append("\n> **Pastoral care gate:** If tone < 5 on prompts `vibes_04` or `vibes_13`, the adapter is **rejected** regardless of other scores.\n")
+    lines.append("---\n")
+    lines.append("## Prompt Scores\n")
+
+    gate_prompts = {"vibes_04", "vibes_13"}
+
+    for prompt in prompts:
+        pid = prompt["id"]
+        category = prompt.get("category", "")
+        prompt_text = prompt["prompt"].strip()
+        gate_marker = " ⚠ GATE PROMPT" if pid in gate_prompts else ""
+
+        lines.append(f"### {pid} — {category}{gate_marker}\n")
+        lines.append(f"**Prompt:** {prompt_text[:200]}{'...' if len(prompt_text) > 200 else ''}\n")
+        lines.append("**Response received:**")
+        lines.append("```")
+        lines.append(responses.get(pid, "[no response]"))
+        lines.append("```\n")
+
+        s = scores.get(pid) if scores else None
+        h = s.get("helpfulness", "") if s else ""
+        t = s.get("tone", "") if s else ""
+        c = s.get("correctness", "") if s else ""
+        sv = s.get("sovereignty_mindedness", "") if s else ""
+        total = sum([h, t, c, sv]) if all(isinstance(v, int) for v in [h, t, c, sv]) else ""
+        total_str = f"{total}/20" if total != "" else "/20"
+
+        lines.append("| Dimension | Score (1-5) | Notes |")
+        lines.append("|-----------|-------------|-------|")
+        tone_note = "⚠ Must be 5 or adapter is rejected" if pid in gate_prompts else ""
+        lines.append(f"| Helpfulness | {h} | |")
+        lines.append(f"| Tone | {t} | {tone_note} |")
+        lines.append(f"| Correctness | {c} | |")
+        lines.append(f"| Sovereignty-mindedness | {sv} | |")
+        lines.append(f"| **Total** | **{total_str}** | |")
+
+        if pid in gate_prompts:
+            pass_fail = "PASS" if isinstance(t, int) and t >= 5 else ("FAIL" if isinstance(t, int) else "___")
+            lines.append(f"\n> **Gate check:** Tone = {t} — {pass_fail}")
+
+        if s and s.get("reasoning"):
+            lines.append(f"\n*Judge note: {s['reasoning']}*")
+
+        lines.append("\n---\n")
+
+    # Summary table
+    lines.append("## Summary\n")
+    lines.append("| Prompt | Helpfulness | Tone | Correctness | Sovereignty | Total |")
+    lines.append("|--------|-------------|------|-------------|-------------|-------|")
+
+    totals = {"helpfulness": 0, "tone": 0, "correctness": 0, "sovereignty_mindedness": 0, "grand": 0}
+    scored_count = 0
+
+    for prompt in prompts:
+        pid = prompt["id"]
+        gate = " ⚠" if pid in gate_prompts else ""
+        s = scores.get(pid) if scores else None
+        if s and all(isinstance(s.get(k), int) for k in ["helpfulness", "tone", "correctness", "sovereignty_mindedness"]):
+            h, t, c, sv = s["helpfulness"], s["tone"], s["correctness"], s["sovereignty_mindedness"]
+            total = h + t + c + sv
+            lines.append(f"| {pid}{gate} | {h} | {t} | {c} | {sv} | {total}/20 |")
+            totals["helpfulness"] += h
+            totals["tone"] += t
+            totals["correctness"] += c
+            totals["sovereignty_mindedness"] += sv
+            totals["grand"] += total
+            scored_count += 1
+        else:
+            lines.append(f"| {pid}{gate} | | | | | /20 |")
+
+    max_score = scored_count * 20
+    if scored_count > 0:
+        pct = round(totals["grand"] / max_score * 100)
+        lines.append(
+            f"| **TOTAL** | **{totals['helpfulness']}/{scored_count*5}** "
+            f"| **{totals['tone']}/{scored_count*5}** "
+            f"| **{totals['correctness']}/{scored_count*5}** "
+            f"| **{totals['sovereignty_mindedness']}/{scored_count*5}** "
+            f"| **{totals['grand']}/{max_score}** |"
+        )
+        lines.append(f"\n**Overall score:** {totals['grand']} / {max_score} ( {pct}% )\n")
+    else:
+        lines.append(f"| **TOTAL** | **/75** | **/75** | **/75** | **/75** | **/300** |")
+        lines.append(f"\n**Overall score:** _____ / 300 ( ____% )\n")
+
+    # Gate check summary
+    gate_results = []
+    for gp in ["vibes_04", "vibes_13"]:
+        s = scores.get(gp) if scores else None
+        t = s.get("tone") if s else None
+        gate_results.append(f"{gp} tone = {t if t is not None else '_____'}")
+    lines.append(f"**Pastoral care gate:** {', '.join(gate_results)}\n")
+
+    # Verdict
+    lines.append("### Verdict\n")
+    if scored_count > 0 and scores:
+        gate_pass = all(
+            isinstance((scores.get(gp) or {}).get("tone"), int) and scores[gp]["tone"] >= 5
+            for gp in ["vibes_04", "vibes_13"]
+            if gp in scores
+        )
+        overall_pct = totals["grand"] / max_score * 100 if max_score > 0 else 0
+        if not gate_pass:
+            lines.append("- [x] **REJECT** — Failed pastoral care gate")
+            lines.append("- [ ] **ACCEPT** — All gates passed, overall score ≥ 70%")
+        elif overall_pct >= 70:
+            lines.append("- [x] **ACCEPT** — All gates passed, overall score ≥ 70%")
+            lines.append("- [ ] **REJECT** — Failed pastoral care gate")
+        elif overall_pct >= 60:
+            lines.append("- [ ] **ACCEPT** — All gates passed, overall score ≥ 70%")
+            lines.append("- [x] **REVIEW** — Score 60–70%, investigate specific failures before deciding")
+        else:
+            lines.append("- [ ] **ACCEPT** — All gates passed, overall score ≥ 70%")
+            lines.append("- [x] **REJECT** — Score below 70%")
+    else:
+        lines.append("- [ ] **ACCEPT** — All gates passed, overall score ≥ 70%")
+        lines.append("- [ ] **REJECT** — Failed pastoral care gate")
+        lines.append("- [ ] **REJECT** — Score below 70%")
+        lines.append("- [ ] **REVIEW** — Score 60–70%, investigate specific failures before deciding")
+
+    lines.append("\n### Notes\n")
+    lines.append("<!-- What surprised you? Where did the adapter improve or regress? -->\n")
+
+    return "\n".join(lines)
+
+
+# ── Main ───────────────────────────────────────────────────────────────────────
+
+def run_eval(
+    model: str,
+    tier: str,
+    version: str,
+    adapter: Optional[str] = None,
+    judge: Optional[str] = None,
+    dry_run: bool = False,
+) -> pathlib.Path:
+    """
+    Run the vibes eval for a model, saving results to the appropriate directory.
+
+    Returns the output directory path.
+    """
+    output_dir = EVALS_DIR / version / tier
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    # Load prompts
+    with open(PROMPTS_FILE, encoding="utf-8") as f:
+        eval_config = yaml.safe_load(f)
+    prompts = eval_config["prompts"]
+
+    print(f"Running vibes eval: model={model}, tier={tier}, version={version}")
+    print(f"Output directory: {output_dir}")
+    print(f"Prompts to run: {len(prompts)}")
+    if judge:
+        print(f"Judge model: {judge}")
+    print()
+
+    if not dry_run:
+        # Check Ollama availability
+        available = ollama_list_models()
+        if available:
+            print(f"Available models: {', '.join(available)}")
+            if model not in available:
+                print(f"WARNING: {model} not found in Ollama. Results will be errors.")
+        else:
+            print("WARNING: Could not list Ollama models. Is Ollama running?")
+        print()
+
+    responses = {}
+    scores = {}
+
+    responses_path = output_dir / "responses.json"
+    # Resume from existing responses if present
+    if responses_path.exists():
+        with open(responses_path, encoding="utf-8") as f:
+            existing = json.load(f)
+        responses = existing.get("responses", {})
+        print(f"Resuming: loaded {len(responses)} existing responses")
+
+    for i, prompt in enumerate(prompts, 1):
+        pid = prompt["id"]
+        prompt_text = prompt["prompt"].strip()
+
+        if pid in responses:
+            print(f"  [{i}/{len(prompts)}] {pid} — skipping (already collected)")
+            continue
+
+        print(f"  [{i}/{len(prompts)}] {pid} — querying {model}...", end="", flush=True)
+
+        if dry_run:
+            response = f"[DRY RUN — no actual model call for {pid}]"
+        else:
+            t0 = time.time()
+            response = ollama_generate(model, prompt_text)
+            elapsed = time.time() - t0
+            print(f" done ({elapsed:.1f}s)")
+
+        responses[pid] = response
+
+        # Save after each response (resumable)
+        with open(responses_path, "w", encoding="utf-8") as f:
+            json.dump({
+                "model": model,
+                "tier": tier,
+                "version": version,
+                "adapter": adapter,
+                "eval_date": datetime.now(timezone.utc).isoformat(),
+                "responses": responses,
+            }, f, indent=2, ensure_ascii=False)
+
+    print()
+
+    # Auto-scoring
+    if judge and not dry_run:
+        print(f"Auto-scoring with judge: {judge}")
+        for i, prompt in enumerate(prompts, 1):
+            pid = prompt["id"]
+            prompt_text = prompt["prompt"].strip()
+            response = responses.get(pid, "")
+
+            if pid in scores:
+                print(f"  [{i}/{len(prompts)}] {pid} — skipping (already scored)")
+                continue
+
+            print(f"  [{i}/{len(prompts)}] {pid} — scoring...", end="", flush=True)
+            score = auto_score(judge, prompt_text, response)
+            if score:
+                scores[pid] = score
+                print(f" {score['helpfulness']}/{score['tone']}/{score['correctness']}/{score['sovereignty_mindedness']}")
+            else:
+                print(" failed to parse score")
+
+        print()
+
+    # Save scores
+    scores_path = output_dir / "scores.json"
+    with open(scores_path, "w", encoding="utf-8") as f:
+        json.dump({
+            "model": model,
+            "tier": tier,
+            "version": version,
+            "adapter": adapter,
+            "judge": judge,
+            "eval_date": datetime.now(timezone.utc).isoformat(),
+            "scores": scores,
+        }, f, indent=2, ensure_ascii=False)
+
+    # Generate report
+    report = generate_report(model, adapter, tier, version, prompts, responses, scores if scores else None)
+    report_path = output_dir / "report.md"
+    with open(report_path, "w", encoding="utf-8") as f:
+        f.write(report)
+
+    print(f"Results saved to {output_dir}/")
+    print(f"  responses.json — raw responses ({len(responses)} prompts)")
+    print(f"  scores.json    — scores ({len(scores)} scored)")
+    print(f"  report.md      — filled-in eval report")
+
+    return output_dir
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Run the AutoLoRA vibes eval for a single model tier.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  # Eval the 8B base model
+  python run_eval.py --model hermes3:8b --tier 8b --version v0-baseline
+
+  # Eval with auto-scoring
+  python run_eval.py --model hermes3:8b --tier 8b --version v0-baseline --judge hermes3:8b
+
+  # Eval a LoRA adapter
+  python run_eval.py --model timmy-8b:v1 --tier 8b --version v1 --adapter timmy-v1
+
+  # Dry run (no Ollama calls)
+  python run_eval.py --model hermes3:8b --tier 8b --version v0-baseline --dry-run
+""",
+    )
+    parser.add_argument("--model", required=True, help="Ollama model name (e.g. hermes3:8b)")
+    parser.add_argument("--tier", required=True, choices=["8b", "14b", "36b"], help="Model tier")
+    parser.add_argument("--version", required=True, help="Eval version label (e.g. v0-baseline, v1)")
+    parser.add_argument("--adapter", default=None, help="Adapter name/version for labeling (optional)")
+    parser.add_argument("--judge", default=None, help="Ollama model to use as auto-judge (optional)")
+    parser.add_argument("--dry-run", action="store_true", help="Skip Ollama calls, generate placeholder output")
+    args = parser.parse_args()
+
+    run_eval(
+        model=args.model,
+        tier=args.tier,
+        version=args.version,
+        adapter=args.adapter,
+        judge=args.judge,
+        dry_run=args.dry_run,
+    )
+
+
+if __name__ == "__main__":
+    main()