diff --git a/FIGURE_SKILL_FLOW.md b/FIGURE_SKILL_FLOW.md
new file mode 100644
index 00000000..68d9e41c
--- /dev/null
+++ b/FIGURE_SKILL_FLOW.md
@@ -0,0 +1,389 @@
+# Auto Research 画图 Skill 接入流程
+
+本文用于 walk through `zhizhou-dev` 中论文画图 Skill 从“被发现”到“进入最终审稿 PDF”的完整流程。
+
+## 1. 总览
+
+当前实现可以概括为:
+
+> **Prompt 软接入 + Author 主动执行 + Readiness Gate 硬验证 + PDF Reviewer 检查**
+
+```mermaid
+flowchart TD
+ A["loom/skills/ar/figures/*/SKILL.md"]
+ B["figure_skills() 扫描 Skill"]
+ C["figure_skills_block() 生成 Skill 菜单"]
+ D["author_draft_prompt() author_round_prompt()"]
+ E["Author Agent 收到 Prompt"]
+ F{"选择需要的 Skill"}
+ G["读取对应 SKILL.md"]
+ H["使用 Skill 的 scripts/ 和 example"]
+ I["在 work/code/ 编写绘图脚本"]
+ J["生成 work/manuscript/figures/*.pdf/png"]
+ K["论文通过 includegraphics 引用图片"]
+ L["Review Readiness Gate"]
+ M["编译 work/manuscript/main.pdf"]
+ N["三个 Cursor Reviewer 并行审阅 PDF"]
+
+ A --> B --> C --> D --> E --> F --> G --> H --> I --> J --> K --> L
+ L -->|失败| E
+ L -->|通过| M --> N
+```
+
+## 2. 第一步:Skill 存放位置
+
+所有被 AR Author 暴露的相关 Skill 位于:
+
+[`loom/skills/ar/figures/`](loom/skills/ar/figures/)
+
+### 真正负责画图的五个 Skill
+
+| Skill | 用途 | 入口 |
+|---|---|---|
+| `results-figure-1` | 实验结果、scaling、ablation 等测量图 | [`results-figure-1/SKILL.md`](loom/skills/ar/figures/results-figure-1/SKILL.md) |
+| `results-figure-2` | 多 seed、per-trial、方差和分布图 | [`results-figure-2/SKILL.md`](loom/skills/ar/figures/results-figure-2/SKILL.md) |
+| `teaser-figure-1` | 彩色三栏 problem/method/result 方法概览图 | [`teaser-figure-1/SKILL.md`](loom/skills/ar/figures/teaser-figure-1/SKILL.md) |
+| `teaser-figure-2` | 白底会议风格 teaser,最后一栏使用真实测量图 | [`teaser-figure-2/SKILL.md`](loom/skills/ar/figures/teaser-figure-2/SKILL.md) |
+| `teaser-figure-3` | **默认 Teaser**;使用 Cursor GenerateImage / Nano Banana、参考图和语义修正迭代生成 icon-rich pipeline teaser | [`teaser-figure-3/SKILL.md`](loom/skills/ar/figures/teaser-figure-3/SKILL.md) |
+
+同一目录下还有:
+
+[`checkbib/SKILL.md`](loom/skills/ar/figures/checkbib/SKILL.md)
+
+它负责核验引用,不负责画图。但当前 `figure_skills()` 会扫描该目录下所有 `SKILL.md`,所以它也会出现在 Author 的 Skill 菜单里。
+
+默认选择规则:
+
+```text
+Auto Research 判断论文需要创建或刷新
+teaser / Figure 1 / overview / architecture / pipeline
+→ teaser-figure-3
+(不等待用户请求;明确的用户风格覆盖默认)
+
+明确要求确定性矢量、复杂公式,或图片生成不可用
+→ teaser-figure-1 / teaser-figure-2
+
+定量实验结果
+→ results-figure-1 / results-figure-2
+```
+
+## 3. 第二步:Python 自动发现 Skill
+
+入口函数位于:
+
+[`loom/ar_task.py`](loom/ar_task.py) → `figure_skills()`
+
+核心逻辑:
+
+```python
+FIGURE_SKILLS_SUBDIR = "figures"
+
+root = ar_skills_dir() / FIGURE_SKILLS_SUBDIR
+for skill in sorted(root.iterdir()):
+ doc = skill / "SKILL.md"
+```
+
+`figure_skills()` 会:
+
+1. 定位 `loom/skills/ar/figures/`。
+2. 遍历每个子目录。
+3. 检查是否存在 `SKILL.md`。
+4. 读取文件开头约 4,000 个字符。
+5. 从 YAML frontmatter 中提取:
+ - `name`
+ - `description`
+6. 返回名称、简短描述和绝对路径。
+
+返回结构类似:
+
+```python
+{
+ "name": "results-figure-1",
+ "description": "Draw a results figure ...",
+ "path": ".../loom/skills/ar/figures/results-figure-1/SKILL.md",
+}
+```
+
+注意:此处不会把所有 Skill 的完整正文加载进 Prompt,只提取菜单信息。
+
+## 4. 第三步:生成注入 Prompt 的 Skill 菜单
+
+入口函数:
+
+[`loom/ar_task.py`](loom/ar_task.py) → `figure_skills_block()`
+
+它把发现的 Skill 组织成以下文本:
+
+```text
+Figure skills are installed. Read the SKILL.md before drawing ...
+
+results-figure-1 -
+ /results-figure-1/SKILL.md
+
+results-figure-2 -
+ /results-figure-2/SKILL.md
+
+...
+```
+
+这意味着 Python 只告诉 Author:
+
+- 有哪些 Skill;
+- 每个 Skill 负责什么;
+- 完整说明文件在哪里。
+
+真正的绘图规范、脚本接口、配色和示例仍然保存在对应 `SKILL.md` 中。
+
+## 5. 第四步:菜单注入 Author Prompt
+
+`figure_skills_block()` 被注入两个关键 Prompt。
+
+### Draft 阶段
+
+[`loom/ar_task.py`](loom/ar_task.py) → `author_draft_prompt()`
+
+```python
+{figure_skills_block()}
+```
+
+Draft 阶段主要让 Author 知道后续可使用哪些画图能力。由于这一阶段只写论文骨架,图片通常仍保留为 `\ARfig{...}`。
+
+### 正式 Author Round
+
+[`loom/ar_task.py`](loom/ar_task.py) → `author_round_prompt()`
+
+```python
+{figure_skills_block()}
+{stuck_block}
+{feedback}
+```
+
+正式写作轮次中,Author 会同时收到:
+
+1. AR Author 方法论;
+2. Figure Skill 菜单;
+3. Plateau 时的结构性修改要求;
+4. 上一轮 Reviewer 的完整意见。
+
+所以 Reviewer 如果指出缺少 Figure、图表不可读或证据不足,下一轮 Author 能从同一个 Prompt 中找到对应画图 Skill。
+
+## 6. 第五步:Author 选择并读取 Skill
+
+这里不是 Python 自动执行 `/results-figure-1`。
+
+实际行为是:
+
+1. Author 判断当前缺少哪类图片。
+2. 根据 Prompt 中的菜单选择 Skill。
+3. 使用文件读取工具打开对应 `SKILL.md`。
+4. 查看 Skill 自带的 `scripts/`、示例代码和示例数据。
+5. 在任务自己的代码仓库中实现绘图脚本。
+6. 运行脚本生成矢量 PDF 或 PNG。
+
+例如 `results-figure-1` 自带:
+
+- [`scripts/plot_style.py`](loom/skills/ar/figures/results-figure-1/scripts/plot_style.py)
+- [`example.py`](loom/skills/ar/figures/results-figure-1/example.py)
+- [`example_data.json`](loom/skills/ar/figures/results-figure-1/example_data.json)
+- [`example.png`](loom/skills/ar/figures/results-figure-1/example.png)
+
+`teaser-figure-1` 自带:
+
+- [`scripts/overview_style.py`](loom/skills/ar/figures/teaser-figure-1/scripts/overview_style.py)
+- [`example.py`](loom/skills/ar/figures/teaser-figure-1/example.py)
+- [`example.png`](loom/skills/ar/figures/teaser-figure-1/example.png)
+
+`teaser-figure-3` 自带:
+
+- [`PROMPT_TEMPLATE.md`](loom/skills/ar/figures/teaser-figure-3/PROMPT_TEMPLATE.md)
+- [`example.png`](loom/skills/ar/figures/teaser-figure-3/example.png)
+
+它不直接运行 matplotlib,而是先冻结语义 blueprint,再调用 Cursor
+`GenerateImage`,随后通过人工检查和版本化 correction prompt 修正文字与箭头端点。
+
+## 7. 第六步:图片进入新的双 Repo 布局
+
+当前 Paper Task 使用两个独立 Git repo:
+
+```text
+.RUD//work/
+├── code/ # 实验与绘图脚本
+└── manuscript/ # LaTeX 论文
+ ├── main.tex
+ ├── sections/
+ └── figures/ # 最终图片
+```
+
+布局定义在:
+
+[`loom/ar_task.py`](loom/ar_task.py)
+
+关键函数:
+
+- `work_root()`
+- `code_root()`
+- `paper_root()`
+- `init_paper_workspace()`
+
+推荐的数据流:
+
+```text
+work/code/results.json
+ ↓
+work/code/plot_result.py
+ ↓
+work/manuscript/figures/result.pdf
+ ↓
+\includegraphics{figures/result}
+```
+
+### 当前已知路径不一致
+
+部分 Figure Skill 仍写着旧布局:
+
+```text
+code/
+latex/figs/
+```
+
+例如:
+
+- [`results-figure-1/SKILL.md`](loom/skills/ar/figures/results-figure-1/SKILL.md)
+- [`teaser-figure-2/SKILL.md`](loom/skills/ar/figures/teaser-figure-2/SKILL.md)
+
+但 AR 当前真实路径是:
+
+```text
+work/code/
+work/manuscript/figures/
+```
+
+因此 walk through 时需要特别留意:Skill 的绘图规范仍然有效,但其中旧的输出路径需要替换为 `../manuscript/figures/` 或对应绝对路径。
+
+## 8. 第七步:Readiness Gate 强制验证图片完成
+
+即使 Author 没有正确使用 Skill,Reviewer 也不会立刻收到未完成论文。
+
+入口:
+
+[`loom/ar_task.py`](loom/ar_task.py) → `review_readiness()`
+
+图片相关检查包括:
+
+### 8.1 不允许残留 `\ARfig`
+
+`review_readiness()` 会扫描所有有效 LaTeX source,任何以下 marker 都会阻止 Review:
+
+```text
+\ARTODO
+\ARnum
+\ARfig
+TODO
+TBD
+FIXME
+XXX
+??
+```
+
+### 8.2 `\includegraphics` 文件必须存在
+
+入口:
+
+[`loom/ar_task.py`](loom/ar_task.py) → `_missing_graphics()`
+
+它解析:
+
+```latex
+\includegraphics[...]{figures/result}
+```
+
+然后检查对应的:
+
+```text
+.pdf
+.png
+.jpg
+.jpeg
+.eps
+```
+
+是否真实存在。
+
+### 8.3 编译 PDF 中不能出现占位图
+
+Readiness Gate 使用 `pypdf` 读取最终 PDF,检查是否仍然显示:
+
+```text
+FIGURE PLACEHOLDER
+TODO
+TBD
+??
+```
+
+### 8.4 PDF 必须能干净编译
+
+入口:
+
+[`loom/ar_task.py`](loom/ar_task.py) → `build_pdf()`
+
+缺图片、错误引用、LaTeX 报错或不可读取的 PDF 都会阻止进入 Reviewer。
+
+## 9. 第八步:Reviewer 只看最终 PDF
+
+只有 Readiness Gate 通过后才会调用:
+
+[`loom/ar_task.py`](loom/ar_task.py) → `run_reviewer()`
+
+流程:
+
+1. 将编译后的 PDF 复制到隔离临时目录。
+2. 临时目录中只放 `submission.pdf`。
+3. 并行启动三个 Cursor Reviewer:
+ - `gpt-5.6-sol-max`
+ - `claude-fable-5-thinking-max`
+ - `cursor-grok-4.5-high`
+4. Reviewer 检查:
+ - 图片是否清晰;
+ - 标签是否可读;
+ - 是否 clipping;
+ - 图中数字是否支持 claim;
+ - 图和 caption 是否一致。
+5. 保存三份完整 Review。
+6. 采用最低 Rating Reviewer 的整套评分作为本轮结果。
+
+Reviewer 看不到绘图脚本和 LaTeX,只评价人类最终会看到的 PDF。
+
+## 10. 建议 Walk Through 顺序
+
+按以下顺序阅读代码:
+
+1. [`loom/skills/ar/figures/`](loom/skills/ar/figures/)
+ 先了解有哪些 Skill。
+
+2. [`loom/ar_task.py`](loom/ar_task.py) → `figure_skills()`
+ 看 Python 如何扫描目录。
+
+3. [`loom/ar_task.py`](loom/ar_task.py) → `figure_skills_block()`
+ 看菜单文本如何生成。
+
+4. [`loom/ar_task.py`](loom/ar_task.py) → `author_draft_prompt()`
+ 看 Draft 阶段如何注入。
+
+5. [`loom/ar_task.py`](loom/ar_task.py) → `author_round_prompt()`
+ 看正式写作轮次如何注入。
+
+6. 任意一个画图 [`SKILL.md`](loom/skills/ar/figures/results-figure-1/SKILL.md)
+ 跟进 Skill 的脚本、示例和输出规范。
+
+7. [`loom/ar_task.py`](loom/ar_task.py) → `init_paper_workspace()`
+ 确认双 repo 目录布局。
+
+8. [`loom/ar_task.py`](loom/ar_task.py) → `review_readiness()`
+ 看图片完成度如何硬门控。
+
+9. [`loom/ar_task.py`](loom/ar_task.py) → `run_reviewer()`
+ 看最终 PDF 如何进入三模型 Reviewer Panel。
+
+## 11. 一句话总结
+
+画图 Skill 本身不是 Python 自动调用的 pipeline node,而是一组动态暴露给 Author 的本地方法论与脚本模板;Author 主动读取和执行,Readiness Gate 确保图片真实落地,最终 Reviewer 只审阅编译后的 PDF。
diff --git a/README.md b/README.md
index f492dd6c..884967e1 100755
--- a/README.md
+++ b/README.md
@@ -209,8 +209,18 @@ Each of those is a **paper** task, and it walks a fixed pipeline:
3. **Rounds** — by default ten of them. The author agent works in the task's tmux
pane (revising the paper, running experiments locally in the worktree) and
signals the end of its turn by writing `rounds/round-NN/author.md`. Loom then
- compiles the PDF and runs a reviewer agent headlessly, with a different model,
- against a top-conference rubric; its scores and report drive the next round.
+ compiles the PDF and applies a hard readiness gate: no TODO/placeholder
+ markers, missing figures, unresolved citations/references, incomplete core
+ sections, build errors or visible `??` may remain. A blocked paper goes back
+ to the author in the same round with the exact failed checks. Only a complete
+ submission runs three independent Cursor reviewers headlessly:
+ `gpt-5.6-sol-max`, `claude-fable-5-thinking-max`, and
+ `cursor-grok-4.5-high`. Each reviewer sees an isolated workspace containing
+ only the compiled PDF (never the LaTeX source). All reports are preserved;
+ the lowest-Rating reviewer's complete score block is the final verdict. If
+ that lowest score plateaus for three rounds, the fixed panel stays in place
+ and the author must make a structural change. Two more rounds without
+ improvement pause the loop for a human decision.
4. **Final review** — a gate. Approve to deliver and download the PDF, or send it
back for another batch of rounds.
diff --git a/loom/ar_task.py b/loom/ar_task.py
index 5ee17ac1..6011e2d6 100644
--- a/loom/ar_task.py
+++ b/loom/ar_task.py
@@ -27,10 +27,14 @@
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
+from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
+from tempfile import TemporaryDirectory
from typing import Any
+from pypdf import PdfReader
+
from loom.paths import ar_root, bundled_skills_path, paper_templates_dir
from loom.rud_task import RUD_DIR, WORK_SUBDIR, slugify, task_root
@@ -63,6 +67,7 @@ def is_ar_kind(kind: str | None) -> bool:
# both show up in the Changes tab.
CODE_SUBDIR = "code"
MANUSCRIPT_SUBDIR = "manuscript"
+LEGACY_PAPER_SUBDIR = "paper"
AUTHOR_NOTE = "author.md"
REVIEW_NOTE = "review.md"
@@ -97,6 +102,18 @@ def is_ar_kind(kind: str | None) -> bool:
MODE_AUTO = "auto"
MODE_SEED = "seed"
+# Cursor's account-scoped model catalog exposes these as the strongest
+# non-fast variants currently available for the requested reviewer families.
+# Fable has an explicit Thinking variant. GPT-5.6 Sol and Cursor Grok do not
+# expose a separate Thinking switch; max/high is their strongest reasoning
+# preset, and Cursor intentionally suppresses private reasoning in print mode.
+CURSOR_REVIEWER_MODELS: tuple[str, ...] = (
+ "gpt-5.6-sol-max",
+ "claude-fable-5-thinking-max",
+ "cursor-grok-4.5-high",
+)
+CURSOR_REVIEWER_PANEL = "cursor-reviewer-panel"
+
# --- Catalogs ---------------------------------------------------------------
@@ -437,6 +454,7 @@ def new_paper_state(
max_rounds: Any = DEFAULT_MAX_ROUNDS,
author_model: str = "",
reviewer_model: str = "",
+ reviewer_models: list[str] | tuple[str, ...] | None = None,
) -> dict[str, Any]:
v = (venue or "").strip().lower()
if v not in VENUE_IDS:
@@ -455,9 +473,14 @@ def new_paper_state(
"gates": [],
"loop_running": False,
"author_model": author_model,
+ # Keep the singular field so old ar.json readers remain compatible.
"reviewer_model": reviewer_model,
+ "reviewer_models": list(
+ CURSOR_REVIEWER_MODELS if reviewer_models is None else reviewer_models
+ ),
"stop_rating": DEFAULT_STOP_RATING,
"stop_reason": "",
+ "plateau_started_round": 0,
"cost_usd": 0.0,
"paper_dir": "",
"pdf_path": "",
@@ -645,8 +668,30 @@ def work_root(project_root: Path, slug: str) -> Path:
def paper_root(project_root: Path, slug: str) -> Path:
- """``/work/manuscript/`` - the LaTeX sources, its own git repo."""
- return work_root(project_root, slug) / MANUSCRIPT_SUBDIR
+ """The manuscript directory, preserving pre-split AR paper tasks.
+
+ New tasks use ``work/manuscript``. Tasks created before the code/manuscript
+ split stored their paper in ``work/paper`` and persisted that absolute path
+ in ``ar.json``. Respect an in-task persisted path first so a Loom upgrade
+ does not make existing PDFs, builds, readiness checks, or reviews vanish.
+ """
+ work = work_root(project_root, slug).resolve()
+ state = read_ar_state(project_root, slug)
+ persisted = str(state.get("paper_dir") or "").strip()
+ if persisted:
+ candidate: Path | None = Path(persisted).expanduser().resolve()
+ try:
+ candidate.relative_to(work)
+ except ValueError:
+ candidate = None
+ if candidate is not None and (
+ candidate.is_dir() or (candidate / "main.tex").is_file()
+ ):
+ return candidate
+ legacy = work / LEGACY_PAPER_SUBDIR
+ if (legacy / "main.tex").is_file():
+ return legacy
+ return work / MANUSCRIPT_SUBDIR
def code_root(project_root: Path, slug: str) -> Path:
@@ -1760,6 +1805,194 @@ def review_headline(scores: dict[str, Any]) -> str:
return " · ".join(bits) or "no scores parsed"
+def _cursor_models(timeout: int = 30) -> dict[str, Any]:
+ """Return the model ids advertised by the logged-in Cursor CLI account."""
+ try:
+ proc = subprocess.run(
+ ["agent", "models"],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ except FileNotFoundError:
+ return {"ok": False, "error": "Cursor CLI `agent` is not on PATH", "models": []}
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ return {"ok": False, "error": f"could not list Cursor models: {exc}", "models": []}
+ if proc.returncode != 0:
+ detail = (proc.stderr or proc.stdout or "unknown error").strip()
+ return {
+ "ok": False,
+ "error": f"`agent models` failed: {detail[-1000:]}",
+ "models": [],
+ }
+ models: list[str] = []
+ for line in (proc.stdout or "").splitlines():
+ match = re.match(r"^([a-zA-Z0-9][a-zA-Z0-9._-]*)\s+-\s+", line.strip())
+ if match:
+ models.append(match.group(1))
+ return {"ok": True, "models": models}
+
+
+def _run_cursor_headless(
+ prompt: str,
+ model: str,
+ workspace: Path,
+ *,
+ timeout: int = 900,
+ on_line: Any = None,
+) -> dict[str, Any]:
+ """Run one read-only Cursor reviewer and return its final Markdown.
+
+ Cursor's print mode never exposes private thinking tokens. The selected
+ model id controls the maximum available reasoning budget; Ask mode and the
+ absence of ``--force`` keep this reviewer read-only.
+ """
+ cmd = [
+ "agent",
+ "--print",
+ "--workspace",
+ str(workspace),
+ "--mode",
+ "ask",
+ "--trust",
+ "--model",
+ model,
+ "--output-format",
+ "json",
+ prompt,
+ ]
+ if on_line is not None:
+ on_line(f"{model}: reviewing compiled PDF")
+ started = time.monotonic()
+ try:
+ proc = subprocess.run(
+ cmd,
+ cwd=str(workspace),
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ except FileNotFoundError:
+ return {"ok": False, "model": model, "error": "Cursor CLI `agent` is not on PATH"}
+ except subprocess.TimeoutExpired:
+ return {
+ "ok": False,
+ "model": model,
+ "error": f"Cursor review timed out after {timeout}s",
+ }
+ except OSError as exc:
+ return {"ok": False, "model": model, "error": str(exc)}
+ if proc.returncode != 0:
+ detail = (proc.stderr or proc.stdout or "unknown error").strip()
+ return {
+ "ok": False,
+ "model": model,
+ "error": f"Cursor reviewer exited {proc.returncode}: {detail[-1500:]}",
+ }
+ try:
+ payload = json.loads(proc.stdout or "")
+ except json.JSONDecodeError as exc:
+ return {
+ "ok": False,
+ "model": model,
+ "error": f"Cursor reviewer returned invalid JSON: {exc}",
+ "raw": (proc.stdout or "")[-1500:],
+ }
+ if not isinstance(payload, dict):
+ return {"ok": False, "model": model, "error": "Cursor reviewer JSON is not an object"}
+ if payload.get("is_error") is True or payload.get("subtype") == "error":
+ return {
+ "ok": False,
+ "model": model,
+ "error": str(payload.get("result") or payload.get("error") or "Cursor review failed"),
+ }
+ text = str(payload.get("result") or "").strip()
+ if not text:
+ return {"ok": False, "model": model, "error": "Cursor reviewer returned no result"}
+ scores = parse_review_scores(text)
+ if "rating" not in scores:
+ return {
+ "ok": False,
+ "model": model,
+ "error": "Cursor reviewer omitted the required Rating score",
+ }
+ try:
+ cost = float(payload.get("total_cost_usd") or payload.get("cost_usd") or 0.0)
+ except (TypeError, ValueError):
+ cost = 0.0
+ elapsed = round(time.monotonic() - started, 1)
+ if on_line is not None:
+ on_line(f"{model}: {review_headline(scores)} ({elapsed}s)")
+ return {
+ "ok": True,
+ "model": model,
+ "review": text,
+ "scores": scores,
+ "headline": review_headline(scores),
+ "duration_seconds": elapsed,
+ "cost": cost,
+ }
+
+
+def _worst_panel_reviewer(reviewers: list[dict[str, Any]]) -> dict[str, Any]:
+ """The lowest-Rating reviewer, with deterministic pessimistic tie breaks."""
+
+ def key(item: dict[str, Any]) -> tuple[float, int, float, float, float]:
+ scores = item.get("scores") or {}
+ recommendation = str(scores.get("recommendation") or "")
+ severity = (
+ RECOMMENDATIONS.index(recommendation)
+ if recommendation in RECOMMENDATIONS
+ else len(RECOMMENDATIONS)
+ )
+ return (
+ float(scores.get("rating", float("-inf"))),
+ -severity,
+ float(scores.get("soundness", 0)),
+ float(scores.get("contribution", 0)),
+ float(scores.get("presentation", 0)),
+ )
+
+ return min(reviewers, key=key)
+
+
+def _panel_scores(reviewers: list[dict[str, Any]]) -> dict[str, Any]:
+ """Use one coherent score block: the lowest-Rating reviewer's verdict."""
+ if not reviewers:
+ return {}
+ return dict(_worst_panel_reviewer(reviewers).get("scores") or {})
+
+
+def _cursor_pdf_review_prompt(
+ skill_text: str,
+ *,
+ pdf_path: Path,
+ venue: str,
+ round_n: int,
+) -> str:
+ """Prompt one independent reviewer to judge only the compiled PDF."""
+ return (
+ f"{skill_text}\n\n"
+ "=== end reviewer instructions ===\n\n"
+ f"Venue: {venue_entry(venue).get('label')}\n"
+ f"Review round: {round_n}\n\n"
+ "You are one member of a three-model independent reviewer panel. Use the "
+ "full reasoning budget configured by your model and think deeply before "
+ "returning the report. Do not reveal private chain-of-thought; return only "
+ "the required review.\n\n"
+ "The sole paper artifact for this review is the compiled PDF below:\n"
+ f"{pdf_path}\n\n"
+ "Open and inspect every page of that PDF. Judge both the scientific content "
+ "and the rendered artifact (tables, figures, equations, clipping, legibility, "
+ "and page-level presentation). The PDF is the source of truth. Do not search "
+ "for, open, or infer from LaTeX source files, author notes, experiment code, "
+ "or another review. Review the submission cold and independently.\n\n"
+ "Write your review now, in exactly the required markdown structure."
+ )
+
+
def run_reviewer(
paper_dir: Path,
skill_text: str,
@@ -1769,59 +2002,149 @@ def run_reviewer(
round_n: int = 1,
author_note: str = "",
build: dict[str, Any] | None = None,
- model: str = "",
+ readiness: dict[str, Any] | None = None,
+ models: list[str] | tuple[str, ...] | None = None,
timeout: int = 900,
on_line: Any = None,
) -> dict[str, Any]:
- """Review the current state of the paper as a program committee member."""
- source = paper_source_text(paper_dir)
- if not source:
- return {"ok": False, "error": f"no LaTeX sources under {paper_dir}"}
+ """Review a compiled PDF with the fixed three-model Cursor panel.
+ ``idea`` and ``author_note`` remain accepted for API compatibility, but are
+ deliberately not included: reviewers see the same PDF a human reviewer
+ would receive, not the author's framing or raw LaTeX.
+ """
+ del idea, author_note
build = build or {}
- if build.get("ok"):
- build_line = (
- "The paper compiles."
- if build.get("clean")
- else "The paper compiles with LaTeX warnings/errors."
+ pdf_value = str(build.get("pdf") or "").strip()
+ pdf = Path(pdf_value) if pdf_value else paper_dir / "main.pdf"
+ if not build.get("ok") or not pdf.is_file():
+ error = str(build.get("error") or f"compiled PDF not found at {pdf}")
+ return {"ok": False, "error": f"cannot review without a compiled PDF: {error}"}
+
+ gate = readiness or review_readiness(paper_dir, venue=venue, build=build)
+ if not gate.get("ready"):
+ failed = ", ".join(
+ str(item.get("label") or "readiness check")
+ for item in (gate.get("failed") or [])
)
- else:
- build_line = f"The paper does NOT compile: {build.get('error', 'unknown error')}"
+ return {
+ "ok": False,
+ "error": "review readiness gate blocked the reviewer panel"
+ + (f": {failed}" if failed else ""),
+ "readiness": gate,
+ }
- idea_block = idea_summary(idea) if idea else "(not recorded)"
- author_block = (author_note or "").strip() or "(the author left no note this round)"
+ selected = tuple(models or CURSOR_REVIEWER_MODELS)
+ if selected != CURSOR_REVIEWER_MODELS:
+ return {
+ "ok": False,
+ "error": (
+ "reviewer panel must use exactly: "
+ + ", ".join(CURSOR_REVIEWER_MODELS)
+ ),
+ }
+ catalog = _cursor_models()
+ if not catalog.get("ok"):
+ return catalog
+ available = set(catalog.get("models") or [])
+ missing = [model for model in selected if model not in available]
+ if missing:
+ return {
+ "ok": False,
+ "error": "required Cursor reviewer model(s) unavailable: " + ", ".join(missing),
+ "available_models": sorted(available),
+ }
- prompt = (
- f"{skill_text}\n\n"
- "=== end reviewer instructions ===\n\n"
- f"Venue: {venue_entry(venue).get('label')}\n"
- f"Review round: {round_n}\n"
- f"Build status: {build_line}\n\n"
- f"The idea this submission is meant to establish:\n{idea_block}\n\n"
- f"What the authors say they did this round:\n{author_block}\n\n"
- "LaTeX sources of the submission follow. Markers such as \\ARTODO{...}, "
- "\\ARnum{} and \\ARfig{...} are deliberate placeholders for work that has "
- "not been done yet - treat them as honest gaps, not as claims.\n\n"
- f"{source}\n\n"
- "Write your review now, in exactly the required markdown structure."
- )
if on_line is not None:
on_line(
- f"reviewing round {round_n} as {venue_entry(venue).get('label')} "
- f"({len(source)} chars of LaTeX)"
+ f"reviewing compiled PDF with Cursor panel: {', '.join(selected)}"
)
- res = _run_headless(prompt, model=model, timeout=timeout, on_line=on_line)
- if not res.get("ok"):
- return res
- text = str(res.get("text") or "").strip()
- scores = parse_review_scores(text)
- if on_line is not None:
- on_line(review_headline(scores))
+
+ with TemporaryDirectory(prefix="loom-ar-pdf-review-") as tmp:
+ workspace = Path(tmp)
+ review_pdf = workspace / "submission.pdf"
+ try:
+ shutil.copy2(pdf, review_pdf)
+ except OSError as exc:
+ return {"ok": False, "error": f"could not isolate compiled PDF: {exc}"}
+ prompt = _cursor_pdf_review_prompt(
+ skill_text,
+ pdf_path=review_pdf,
+ venue=venue,
+ round_n=round_n,
+ )
+ by_model: dict[str, dict[str, Any]] = {}
+ with ThreadPoolExecutor(max_workers=len(selected)) as pool:
+ futures = {
+ pool.submit(
+ _run_cursor_headless,
+ prompt,
+ model,
+ workspace,
+ timeout=timeout,
+ on_line=on_line,
+ ): model
+ for model in selected
+ }
+ for future in as_completed(futures):
+ model = futures[future]
+ try:
+ by_model[model] = future.result()
+ except Exception as exc: # noqa: BLE001
+ by_model[model] = {"ok": False, "model": model, "error": str(exc)}
+
+ reviewers = [by_model[model] for model in selected]
+ failures = [item for item in reviewers if not item.get("ok")]
+ if failures:
+ detail = "; ".join(
+ f"{item.get('model')}: {item.get('error', 'unknown error')}"
+ for item in failures
+ )
+ return {
+ "ok": False,
+ "error": f"Cursor reviewer panel incomplete: {detail}",
+ "reviewers": reviewers,
+ }
+
+ scores = _panel_scores(reviewers)
+ deciding = _worst_panel_reviewer(reviewers)
+ deciding_model = str(deciding.get("model") or "")
+ headline = (
+ f"{len(reviewers)} reviewers · lowest: {deciding_model} · "
+ f"{review_headline(scores)}"
+ )
+ cost = round(sum(float(item.get("cost") or 0.0) for item in reviewers), 4)
+ sections = [
+ "# Cursor Reviewer Panel",
+ "",
+ f"**Round:** {round_n}",
+ f"**Input:** compiled PDF only (`{pdf.name}`)",
+ f"**Models:** {', '.join(selected)}",
+ f"**Deciding reviewer (lowest Rating):** `{deciding_model}`",
+ f"**Final score:** {review_headline(scores)}",
+ ]
+ for item in reviewers:
+ sections.extend(
+ [
+ "",
+ "---",
+ "",
+ f"# Reviewer: `{item['model']}`",
+ "",
+ str(item["review"]).strip(),
+ ]
+ )
+ text = "\n".join(sections).strip() + "\n"
return {
"ok": True,
"review": text,
"scores": scores,
- "headline": review_headline(scores),
+ "headline": headline,
+ "models": list(selected),
+ "reviewers": reviewers,
+ "deciding_model": deciding_model,
+ "cost": cost,
+ "input_pdf": str(pdf),
}
@@ -1912,9 +2235,10 @@ def loop_is_complete(state: dict[str, Any]) -> bool:
DEFAULT_STOP_RATING = 8
# Consecutive reviews without improvement before we treat the loop as stuck.
PLATEAU_WINDOW = 3
-# Rotated through when the score stops moving: a second opinion from the same
-# model tends to repeat the same asks, and repeating them is what stalled.
-REVIEWER_ROTATION = ("claude-fable-5", "claude-opus-4-8", "claude-sonnet-5")
+# Keep the fixed three-model jury for a consistent yardstick. If two more
+# completed rounds fail to clear a plateau, stop and ask a human instead of
+# gaming the score by replacing the strictest reviewer.
+PLATEAU_HUMAN_GRACE_ROUNDS = 2
SCORE_DIMENSIONS = ("soundness", "presentation", "contribution")
@@ -1966,12 +2290,29 @@ def stuck_dimensions(state: dict[str, Any], window: int = PLATEAU_WINDOW) -> lis
return out
-def reviewer_model_for(state: dict[str, Any], base: str, round_n: int) -> str:
- """Reviewer model for a round, rotated once the score stops moving."""
+def update_plateau_tracking(state: dict[str, Any], round_n: int) -> int:
+ """Record when the current score plateau began; reset after improvement."""
if not is_plateaued(state):
- return base
- rotation = [m for m in REVIEWER_ROTATION if m != base] or list(REVIEWER_ROTATION)
- return rotation[round_n % len(rotation)]
+ state["plateau_started_round"] = 0
+ return 0
+ try:
+ started = int(state.get("plateau_started_round") or 0)
+ except (TypeError, ValueError):
+ started = 0
+ if started <= 0:
+ started = int(round_n)
+ state["plateau_started_round"] = started
+ return started
+
+
+def should_pause_for_plateau(
+ state: dict[str, Any],
+ round_n: int,
+ grace_rounds: int = PLATEAU_HUMAN_GRACE_ROUNDS,
+) -> bool:
+ """Pause after a fixed jury stays plateaued through two repair rounds."""
+ started = update_plateau_tracking(state, round_n)
+ return started > 0 and int(round_n) - started >= int(grace_rounds)
def plateau_note(state: dict[str, Any], window: int = PLATEAU_WINDOW) -> str:
@@ -1981,12 +2322,12 @@ def plateau_note(state: dict[str, Any], window: int = PLATEAU_WINDOW) -> str:
ratings = score_history(state, "rating")
stuck = stuck_dimensions(state, window)
stuck_text = (
- f" {', '.join(stuck)}ha{'s' if len(stuck) == 1 else 've'} not moved at all."
+ f" {', '.join(stuck)} {'has' if len(stuck) == 1 else 'have'} not moved at all."
if stuck
else ""
)
return (
- f"The rating has not improved in {window} rounds "
+ f"The lowest panel rating has not improved in {window} rounds "
f"({', '.join(str(int(r)) for r in ratings[-window:])}).{stuck_text}\n"
"Incremental responses to the review are not working, so do not spend "
"this round on another one. Pick exactly one:\n"
@@ -2218,16 +2559,48 @@ def extract_paper_fields(paper_dir: Path) -> dict[str, Any]:
}
+def _active_tex(text: str) -> str:
+ """Drop LaTeX comments while preserving line numbers for diagnostics."""
+ return re.sub(r"(? list[Path]:
+ """Authored TeX inputs, excluding the file that defines AR markers."""
+ if not paper_dir.is_dir():
+ return []
+ return [
+ path
+ for path in sorted(paper_dir.rglob("*.tex"))
+ if path.name != "ar_macros.tex"
+ ]
+
+
+def _source_findings(
+ paper_dir: Path, pattern: re.Pattern[str], limit: int = 12
+) -> list[str]:
+ findings: list[str] = []
+ for path in _paper_tex_sources(paper_dir):
+ try:
+ text = _active_tex(path.read_text(encoding="utf-8", errors="replace"))
+ except OSError:
+ continue
+ for match in pattern.finditer(text):
+ line = text.count("\n", 0, match.start()) + 1
+ findings.append(f"{path.relative_to(paper_dir)}:{line} ({match.group(0)})")
+ if len(findings) >= limit:
+ return findings
+ return findings
+
+
def count_placeholder_markers(paper_dir: Path) -> int:
- """Unfilled \\ARTODO / \\ARnum / \\ARfig slots left in the paper body."""
+ """Active ``\\ARTODO`` / ``\\ARnum`` / ``\\ARfig`` uses in paper sources."""
total = 0
- sections = paper_dir / "sections"
- files = list(sections.glob("*.tex")) if sections.is_dir() else []
- for path in files:
+ for path in _paper_tex_sources(paper_dir):
try:
- total += len(_MARKER_RE.findall(path.read_text(encoding="utf-8", errors="replace")))
+ text = _active_tex(path.read_text(encoding="utf-8", errors="replace"))
except OSError:
continue
+ total += len(_MARKER_RE.findall(text))
return total
@@ -2235,13 +2608,9 @@ def pdf_page_count(pdf: Path) -> int | None:
if not pdf.is_file():
return None
try:
- proc = subprocess.run(
- ["pdfinfo", str(pdf)], capture_output=True, text=True, timeout=30
- )
- except (OSError, subprocess.TimeoutExpired):
+ return len(PdfReader(str(pdf), strict=False).pages)
+ except Exception: # noqa: BLE001 - malformed third-party PDF input
return None
- match = re.search(r"^Pages:\s+(\d+)", proc.stdout or "", re.MULTILINE)
- return int(match.group(1)) if match else None
def _has_real_results(paper_dir: Path) -> bool:
@@ -2272,6 +2641,257 @@ def _bib_entry_count(paper_dir: Path) -> int:
SEED_BIB_ENTRIES = 3
+_TEXT_PLACEHOLDER_RE = re.compile(r"\b(?:TODO|TBD|FIXME|XXX)\b", re.IGNORECASE)
+_QUESTION_PLACEHOLDER_RE = re.compile(r"\?{2,}")
+_INCLUDEGRAPHICS_RE = re.compile(
+ r"\\includegraphics\s*(?:\[[^\]]*\])?\s*\{([^{}]+)\}",
+ re.IGNORECASE,
+)
+_GRAPHIC_EXTENSIONS = (".pdf", ".png", ".jpg", ".jpeg", ".eps")
+_REQUIRED_SECTIONS = (
+ "00_abstract.tex",
+ "01_introduction.tex",
+ "02_related_work.tex",
+ "03_method.tex",
+ "04_experiments.tex",
+ "05_conclusion.tex",
+)
+_LATEX_BLOCKING_WARNING_RE = re.compile(
+ r"(undefined references?|undefined citations?|"
+ r"(?:Citation|Reference)\s+.+?\s+undefined|"
+ r"Rerun to get cross-references right|"
+ r"Label\(s\) may have changed|multiply defined)",
+ re.IGNORECASE,
+)
+
+
+def _missing_graphics(paper_dir: Path) -> list[str]:
+ missing: list[str] = []
+ seen: set[str] = set()
+ for source in _paper_tex_sources(paper_dir):
+ try:
+ text = _active_tex(source.read_text(encoding="utf-8", errors="replace"))
+ except OSError:
+ continue
+ for match in _INCLUDEGRAPHICS_RE.finditer(text):
+ raw = match.group(1).strip()
+ if raw in seen:
+ continue
+ seen.add(raw)
+ target = Path(raw)
+ roots = (paper_dir, source.parent)
+ candidates: list[Path] = []
+ for root in roots:
+ base = target if target.is_absolute() else root / target
+ if target.suffix:
+ candidates.append(base)
+ else:
+ candidates.extend(base.with_suffix(ext) for ext in _GRAPHIC_EXTENSIONS)
+ if not any(path.is_file() for path in candidates):
+ missing.append(raw)
+ return missing
+
+
+def _pdf_text(pdf: Path, timeout: int = 60) -> dict[str, Any]:
+ """Extract the rendered PDF text so visible placeholders cannot hide."""
+ del timeout # Kept for compatibility with the previous subprocess helper.
+ try:
+ reader = PdfReader(str(pdf), strict=False)
+ text = "\n\f\n".join((page.extract_text() or "") for page in reader.pages)
+ except Exception as exc: # noqa: BLE001 - malformed third-party PDF input
+ return {"ok": False, "error": f"could not inspect rendered PDF: {exc}"}
+ return {"ok": True, "text": text}
+
+
+def _latex_log(paper_dir: Path, build: dict[str, Any]) -> str:
+ try:
+ return (paper_dir / "main.log").read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return str(build.get("log") or "")
+
+
+def review_readiness(
+ paper_dir: Path,
+ *,
+ venue: str = DEFAULT_VENUE,
+ build: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Hard gate before a paper may consume a reviewer turn.
+
+ This deliberately checks submission completeness rather than research
+ quality. A passing paper is compiled, fully rendered, free of placeholders
+ and unresolved references, and contains real results; only the reviewer
+ decides whether that complete submission is scientifically good.
+ """
+ build = build or {}
+ entry = venue_entry(venue)
+ pdf_value = str(build.get("pdf") or "").strip()
+ pdf = Path(pdf_value) if pdf_value else paper_dir / "main.pdf"
+ checks: list[dict[str, Any]] = []
+
+ def check(ok: bool, label: str, detail: str = "") -> None:
+ checks.append({"ok": bool(ok), "label": label, "detail": detail})
+
+ pdf_exists = pdf.is_file()
+ check(
+ bool(build.get("ok")) and pdf_exists,
+ "Compiled PDF exists",
+ str(pdf) if pdf_exists else str(build.get("error") or f"missing {pdf}"),
+ )
+ check(
+ bool(build.get("clean")),
+ "LaTeX build is clean",
+ "latexmk exited 0" if build.get("clean") else "fix every LaTeX build error",
+ )
+
+ marker_locations = _source_findings(paper_dir, _MARKER_RE)
+ marker_count = count_placeholder_markers(paper_dir)
+ check(
+ marker_count == 0,
+ "No AR placeholders remain",
+ "none"
+ if marker_count == 0
+ else f"{marker_count} marker(s): " + ", ".join(marker_locations),
+ )
+
+ text_placeholders = _source_findings(paper_dir, _TEXT_PLACEHOLDER_RE)
+ check(
+ not text_placeholders,
+ "No TODO/TBD/FIXME/XXX text remains",
+ "none" if not text_placeholders else ", ".join(text_placeholders),
+ )
+ question_placeholders = _source_findings(paper_dir, _QUESTION_PLACEHOLDER_RE)
+ check(
+ not question_placeholders,
+ "No unresolved ?? markers remain in sources",
+ "none" if not question_placeholders else ", ".join(question_placeholders),
+ )
+
+ incomplete_sections: list[str] = []
+ sections = paper_dir / "sections"
+ for name in _REQUIRED_SECTIONS:
+ path = sections / name
+ try:
+ active = _active_tex(path.read_text(encoding="utf-8", errors="replace"))
+ except OSError:
+ incomplete_sections.append(f"{name} missing")
+ continue
+ if len(_delatex(active)) < 40:
+ incomplete_sections.append(f"{name} is empty or too short")
+ check(
+ not incomplete_sections,
+ "All core paper sections are substantive",
+ "complete" if not incomplete_sections else "; ".join(incomplete_sections),
+ )
+
+ fields = extract_paper_fields(paper_dir)
+ check(
+ bool(fields["title"]) and len(fields["title"]) <= MAX_TITLE_CHARS,
+ "Title is written and within length",
+ fields["title"] or "no paper title found",
+ )
+ abstract = str(fields["abstract"] or "")
+ check(
+ bool(abstract)
+ and fields["abstract_markers"] == 0
+ and len(abstract) <= MAX_ABSTRACT_CHARS,
+ "Abstract is complete",
+ f"{len(abstract)} chars, {fields['abstract_markers']} marker(s)",
+ )
+ check(
+ _has_real_results(paper_dir),
+ "Experiments contain a real table or figure",
+ "found" if _has_real_results(paper_dir) else "add measured results",
+ )
+ bib = _bib_entry_count(paper_dir)
+ check(
+ bib > SEED_BIB_ENTRIES,
+ "Bibliography goes beyond template seeds",
+ f"{bib} entries",
+ )
+
+ missing_graphics = _missing_graphics(paper_dir)
+ check(
+ not missing_graphics,
+ "Every referenced figure file exists",
+ "all present" if not missing_graphics else "missing: " + ", ".join(missing_graphics),
+ )
+
+ warnings = _LATEX_BLOCKING_WARNING_RE.findall(_latex_log(paper_dir, build))
+ check(
+ not warnings,
+ "No unresolved citations, references, or labels",
+ "none" if not warnings else ", ".join(dict.fromkeys(warnings)),
+ )
+
+ pages = pdf_page_count(pdf) if pdf_exists else None
+ page_limit = int(entry.get("page_limit") or 0)
+ page_ok = pages is not None and (not page_limit or pages <= page_limit + 2)
+ check(
+ page_ok,
+ f"PDF page count is inspectable and within {entry['label']} allowance",
+ (
+ f"{pages} pages (main-text limit {page_limit}, +2 allowance for references)"
+ if pages is not None
+ else "pdfinfo could not read the PDF"
+ ),
+ )
+
+ rendered = _pdf_text(pdf) if pdf_exists else {"ok": False, "error": "PDF missing"}
+ if rendered.get("ok"):
+ pdf_text = str(rendered.get("text") or "")
+ visible = []
+ if _QUESTION_PLACEHOLDER_RE.search(pdf_text):
+ visible.append("??")
+ for match in _TEXT_PLACEHOLDER_RE.finditer(pdf_text):
+ token = match.group(0).upper()
+ if token not in visible:
+ visible.append(token)
+ if "FIGURE PLACEHOLDER" in pdf_text.upper():
+ visible.append("FIGURE PLACEHOLDER")
+ check(
+ not visible,
+ "Rendered PDF has no visible placeholders or question marks",
+ "none" if not visible else ", ".join(visible),
+ )
+ else:
+ check(
+ False,
+ "Rendered PDF can be inspected for visible placeholders",
+ str(rendered.get("error") or "PDF text extraction failed"),
+ )
+
+ return {
+ "ready": all(item["ok"] for item in checks),
+ "checks": checks,
+ "failed": [item for item in checks if not item["ok"]],
+ "pdf": str(pdf),
+ "venue": str(entry.get("id") or venue),
+ "checked_at": _now_iso(),
+ }
+
+
+def review_readiness_markdown(result: dict[str, Any]) -> str:
+ status = "PASS — reviewer may run" if result.get("ready") else "BLOCKED — return to author"
+ lines = [
+ "# Review Readiness Gate",
+ "",
+ f"**Status:** {status}",
+ f"**Checked:** {result.get('checked_at', '')}",
+ f"**PDF:** `{result.get('pdf', '')}`",
+ "",
+ "## Checks",
+ ]
+ for item in result.get("checks") or []:
+ mark = "x" if item.get("ok") else " "
+ detail = str(item.get("detail") or "").strip()
+ lines.append(
+ f"- [{mark}] **{item.get('label', 'check')}**"
+ + (f" — {detail}" if detail else "")
+ )
+ return "\n".join(lines).strip() + "\n"
+
+
def build_submission(
project_root: Path,
slug: str,
@@ -2417,14 +3037,15 @@ def ar_skills_dir() -> Path:
FIGURE_SKILLS_SUBDIR = "figures"
+DEFAULT_TEASER_SKILL = "teaser-figure-3"
def figure_skills() -> list[dict[str, str]]:
"""Paper-figure skills available to the author, newest listing each time.
Only the name, the one-line description and the path go into a prompt: the
- five SKILL.md files together are ~38k characters, so the author is pointed
- at them and reads the one it needs, rather than carrying all five into
+ Figure SKILL.md files together are large, so the author is pointed at them
+ and reads the one it needs, rather than carrying every full skill into
every round.
"""
root = ar_skills_dir() / FIGURE_SKILLS_SUBDIR
@@ -2460,12 +3081,26 @@ def figure_skills_block() -> str:
if not skills:
return ""
lines = [
- "Figure skills are installed. Read the SKILL.md before drawing - each",
- "carries a house style, a drawing kit under its scripts/, and a worked",
- "example you can run:",
+ f"AUTO-RESEARCH DEFAULT TEASER: {DEFAULT_TEASER_SKILL}. Whenever the AR",
+ "author decides the manuscript needs a new or refreshed teaser, Figure 1,",
+ "overview, architecture, or pipeline, automatically use this Cursor",
+ "GenerateImage / Nano Banana workflow. Do not wait for the user to ask",
+ "for a figure or name the skill. An explicit user style override wins.",
+ "Use teaser-figure-1/2 only when the user requests deterministic vector",
+ "output, the figure is equation-heavy, or image generation is unavailable.",
+ "For quantitative evidence plots, use results-figure-1/2 instead.",
+ "Read the selected SKILL.md before drawing:",
]
- for skill in skills:
- lines.append(f" {skill['name']} - {skill['description']}")
+ ordered = sorted(
+ skills,
+ key=lambda skill: (
+ skill["name"] != DEFAULT_TEASER_SKILL,
+ skill["name"],
+ ),
+ )
+ for skill in ordered:
+ marker = " [DEFAULT TEASER]" if skill["name"] == DEFAULT_TEASER_SKILL else ""
+ lines.append(f" {skill['name']}{marker} - {skill['description']}")
lines.append(f" {skill['path']}")
return "\n".join(lines)
@@ -2714,11 +3349,88 @@ def author_round_prompt(
Run the experiments first, then fold the real numbers into the paper, then
rebuild the PDF. Never write a number an experiment did not produce.
+This is a hard review-readiness gate: do not write the completion note until
+the paper is a complete, ready-to-submit artifact. Every \\ARTODO, \\ARnum,
+\\ARfig, TODO/TBD/FIXME/XXX, unresolved ??, missing figure, undefined
+citation/reference, build error, and empty core section must be gone from both
+the sources and the rendered PDF. The experiments section must contain real
+measured results and the bibliography must go beyond the template seeds.
+
When the round is finished, write your summary to:
{note}
Writing that file is how Loom knows the round is over and hands the paper to
-the reviewer, so make it the last thing you do, then stop.
+the deterministic readiness gate. Only a passing gate hands the PDF to the
+reviewers. Make the note the last thing you do, then stop.
+"""
+
+
+def author_readiness_repair_prompt(
+ task_dir: Path,
+ paper_dir: Path,
+ state: dict[str, Any],
+ round_n: int,
+ readiness: dict[str, Any],
+ *,
+ report_path: Path | None = None,
+) -> str:
+ """Return a blocked round to the author with deterministic failures."""
+ venue = venue_entry(str(state.get("venue") or DEFAULT_VENUE)).get("label")
+ note = author_note_path_for(task_dir, round_n)
+ failures = readiness.get("failed") or []
+ failure_lines = "\n".join(
+ f"- {item.get('label', 'check')}: {item.get('detail', '')}"
+ for item in failures
+ ) or "- The gate did not provide details; rerun every readiness check."
+ report = str(report_path) if report_path is not None else "(not written)"
+ return f"""You are still the author of Loom AR paper ROUND {round_n}.
+
+The reviewer panel was NOT called. The deterministic Review Readiness Gate
+blocked this paper because it is not yet a complete, ready-to-submit {venue}
+submission.
+
+Task directory:
+{task_dir}
+
+Paper directory:
+{paper_dir}
+
+Idea this paper must establish:
+{idea_summary(state.get("idea") or {})}
+
+Full gate report:
+{report}
+
+Failures that must all be fixed:
+{failure_lines}
+
+Continue the SAME round. Follow the AR author methodology exactly:
+{ar_skill_text(SKILL_AUTHOR) or "(AR author skill missing)"}
+
+Before signalling completion again, make the whole submission complete:
+
+1. Replace every active \\ARTODO, \\ARnum and \\ARfig with finished prose,
+ measured numbers and real generated figures.
+2. Remove every TODO/TBD/FIXME/XXX and unresolved ?? marker from both the
+ source and rendered PDF. Ordinary question-mark punctuation is allowed;
+ unresolved double-question-mark placeholders are not.
+3. Finish every core section: abstract, introduction, related work, method,
+ experiments and conclusion.
+4. Include real measured results, required baselines, ablations, analysis,
+ seeds/variance where applicable, and cost measurements.
+5. Ensure every \\includegraphics target exists and every figure/table is
+ readable in the compiled PDF.
+6. Resolve every citation, reference and label warning.
+7. Expand the bibliography beyond the three template seed entries.
+8. Run latexmk until it exits cleanly, inspect every PDF page, and stay within
+ the venue page allowance.
+
+Do not ask the reviewers to evaluate unfinished work. When and only when every
+failure above is fixed, write a NEW completion note to:
+{note}
+
+Writing that file is the final action. Loom will rerun the deterministic gate;
+the reviewer panel runs only after it passes.
"""
diff --git a/loom/skills/ar/AR-AUTHOR.md b/loom/skills/ar/AR-AUTHOR.md
index a5494a84..e258337e 100644
--- a/loom/skills/ar/AR-AUTHOR.md
+++ b/loom/skills/ar/AR-AUTHOR.md
@@ -53,15 +53,21 @@ A figure belongs in `manuscript/figures/` and the script that drew it in
Dedicated skills are installed under `loom/skills/ar/figures/`, and the round
prompt lists them with their paths. Read the relevant `SKILL.md` before drawing
-rather than reaching for default matplotlib — each one carries a house style, a
-drawing kit in its `scripts/`, and a runnable example.
-
-- **teaser-figure** and **teaser-figure-plain** — the page-one overview. Use the
- plain variant when the paper has something concrete to draw and a real
- measurement to plot; the tinted-panel variant when the contribution is a
- mechanism with no drawable object.
-- **results-figure** and **results-figure-replicates** — the evidence plots. Use
- the replicates variant whenever you have more than one seed, which for this
+rather than reaching for default matplotlib — each one carries a house style,
+procedural checks and a worked example; deterministic skills also carry a
+drawing kit in `scripts/`.
+
+- **teaser-figure-1**, **teaser-figure-2** and **teaser-figure-3** — page-one
+ overviews. **Auto Research must proactively use teaser-figure-3 whenever it
+ creates or refreshes a teaser, Figure 1, overview, architecture or pipeline;
+ do not wait for the user to request a figure or name the skill.** It uses
+ Cursor GenerateImage and must be manually checked and iterated from reference
+ figures. An explicit user style override wins. Use teaser-figure-2 when
+ deterministic vector output must show concrete objects plus a real
+ measurement; teaser-figure-1 for a deterministic tinted-panel mechanism
+ diagram, complex formulas, or when image generation is unavailable.
+- **results-figure-1** and **results-figure-2** — the evidence plots. Use
+ results-figure-2 whenever you have more than one seed, which for this
pipeline is most of the time: showing the spread is what makes a small-scale
result credible.
- **checkbib** — run it before every gate. The reviewer challenges citations,
@@ -109,8 +115,20 @@ Each round you get the previous round's `review.md`. Work in this order:
that are now measured, and update the abstract and introduction so the
claims match what the tables actually show. Claims shrink when results are
weaker than hoped; that is the correct outcome, not a failure.
-5. **Rebuild the PDF** and fix any LaTeX errors.
-6. **Write `rounds/round-NN/author.md`** and stop. This file is how Loom knows
+5. **Finish the whole submission before review.** Replace every `\ARTODO`,
+ `\ARnum` and `\ARfig`; remove TODO/TBD/FIXME/XXX and unresolved `??`
+ markers; generate every promised figure; finish every core section; resolve
+ every citation/reference; and make every table and figure readable. A
+ reviewer turn is never spent on an unfinished paper.
+6. **Create or refresh the page-one teaser automatically.** If no teaser exists,
+ or if its method/results no longer match the manuscript, run
+ `teaser-figure-3` without waiting for a user request. Freeze the semantic
+ blueprint, generate from content and style references, inspect the actual
+ image, and issue correction versions until every label and arrow is right.
+7. **Rebuild and inspect the PDF.** Run `latexmk` until it exits cleanly, then
+ inspect every rendered page for visible placeholders, clipping, broken
+ references, unreadable figures and page-limit problems.
+8. **Write `rounds/round-NN/author.md`** and stop. This file is how Loom knows
the round is over, so it must be the last thing you do.
`author.md` format:
@@ -125,12 +143,21 @@ Each round you get the previous round's `review.md`. Work in this order:
- -> ->
## Still open
--
+- None. If anything remains open, do not write `author.md`; keep working.
## Build
- latexmk:
```
+`author.md` enters a deterministic Review Readiness Gate before any reviewer is
+called. The gate requires a clean compiled PDF, no active or rendered
+placeholders, substantive core sections, real results, non-template
+bibliography entries, existing figure files, resolved citations/references and
+an inspectable page count within the venue allowance. If it fails, Loom archives
+the completion note, returns the exact failures to you, and keeps you in the
+same round. Fix every failure and write a new `author.md`; never ask reviewers
+to judge work you already know is incomplete.
+
## Experiments run locally
This task's experiments run on the machine Loom is running on, inside `code/`.
diff --git a/loom/skills/ar/AR-REVIEWER.md b/loom/skills/ar/AR-REVIEWER.md
index 64c6dd0c..c7c8bfa1 100644
--- a/loom/skills/ar/AR-REVIEWER.md
+++ b/loom/skills/ar/AR-REVIEWER.md
@@ -5,6 +5,12 @@ NeurIPS or COLM). Review it the way a competent, busy, slightly skeptical
reviewer would: read for the claim, check whether the evidence supports it, and
say so plainly.
+Loom gives you an isolated workspace containing the compiled `submission.pdf`
+and no paper source. Open and inspect every page. The PDF is the submission and
+the sole source of truth: do not search for LaTeX, author notes, experiment
+code, raw logs, or another review. Evaluate both the science and what is
+actually rendered on the page.
+
You are not the author's assistant. Your value to this pipeline comes entirely
from catching what is wrong, so a review that reads as encouragement is a failed
review. The author agent reads your output and acts on it in the next round.
@@ -12,17 +18,16 @@ review. The author agent reads your output and acts on it in the next round.
## Hard rules
1. **Judge the paper in front of you**, not the paper it could become.
-2. **Every weakness names a location** — a section, a table, an equation — and
- states what would fix it. "The evaluation is weak" is useless; "Table 1 has
- no baseline that controls for parameter count, so the gain could be capacity"
- is actionable.
+2. **Every weakness names a PDF location** — page plus section, table, figure,
+ or equation — and states what would fix it. "The evaluation is weak" is
+ useless; "Page 6, Table 1 has no baseline that controls for parameter count,
+ so the gain could be capacity" is actionable.
3. **Unsupported numbers are the most serious defect you can find.** If the
- paper states a result that no described experiment produces, or a `\ARnum{}`
- marker has been replaced by a number with no experimental setup behind it,
- flag it as a soundness violation, not a presentation issue.
-4. **Placeholders are expected in early rounds.** A `\ARTODO{}` or `\ARnum{}`
- is an honest gap; note what is missing and move on. Do not spend the review
- listing every marker.
+ PDF states a result that no experiment described in the PDF produces, flag
+ it as a soundness violation, not a presentation issue.
+4. **Visible placeholders are expected in early rounds.** Treat clearly marked
+ TODO/result/figure placeholders as honest gaps; note what critical evidence
+ is missing and move on. Do not spend the review listing every placeholder.
5. **Do not reward effort.** Length, breadth of related work, and number of
equations do not raise the score. Only evidence for the central claim does.
6. Be concise. A long review dilutes the points that matter.
@@ -53,7 +58,8 @@ Check specifically:
One model family or one benchmark supports a narrow claim, not a broad one.
**Presentation.** Only after the above: clarity, notation, figure quality,
-whether the abstract matches the results.
+whether the abstract matches the results, and rendered-PDF defects such as
+clipping, unreadable labels, broken references, missing glyphs, or overflow.
## Output format
@@ -67,7 +73,7 @@ Reply in exactly this markdown structure, and nothing else:
-
## Weaknesses
-- **[critical|major|minor]** `` - ->
+- **[critical|major|minor]** `` - ->
## Questions for the authors
-
diff --git a/loom/skills/ar/figures/display.md b/loom/skills/ar/figures/display.md
new file mode 100644
index 00000000..bfaf90b2
--- /dev/null
+++ b/loom/skills/ar/figures/display.md
@@ -0,0 +1,33 @@
+# AR Figure Skill Examples
+
+本页通过相对路径直接引用每个 Figure Skill 自带的 `example.png`,不复制图片文件。
+
+## Results Figure 1
+
+[查看 Skill 文档](./results-figure-1/SKILL.md)
+
+
+
+## Results Figure 2
+
+[查看 Skill 文档](./results-figure-2/SKILL.md)
+
+
+
+## Teaser Figure 1
+
+[查看 Skill 文档](./teaser-figure-1/SKILL.md)
+
+
+
+## Teaser Figure 2
+
+[查看 Skill 文档](./teaser-figure-2/SKILL.md)
+
+
+
+## Teaser Figure 3 — Default
+
+[查看 Skill 文档](./teaser-figure-3/SKILL.md)
+
+
diff --git a/loom/skills/ar/figures/results-figure/SKILL.md b/loom/skills/ar/figures/results-figure-1/SKILL.md
similarity index 89%
rename from loom/skills/ar/figures/results-figure/SKILL.md
rename to loom/skills/ar/figures/results-figure-1/SKILL.md
index f11408cf..50a68011 100644
--- a/loom/skills/ar/figures/results-figure/SKILL.md
+++ b/loom/skills/ar/figures/results-figure-1/SKILL.md
@@ -1,16 +1,16 @@
---
-name: results-figure
-description: Draw a results figure for one of this repo's papers — a chart carrying measurements, in the house style the existing figures already use: Okabe-Ito colours, the paper's serif face, TrueType output, references drawn as labelled baselines rather than legend entries, and a printed summary of every number the figure asserts. Use when the user runs /results-figure, or asks for a results plot, an experiment figure, a scaling or ablation chart, or asks to fix, restyle or check an existing figure in a paper's latex/figs. Not for teasers or schematics — those are teaser-figure and teaser-figure-plain.
+name: results-figure-1
+description: Draw a results figure for one of this repo's papers — a chart carrying measurements, in the house style the existing figures already use: Okabe-Ito colours, the paper's serif face, TrueType output, references drawn as labelled baselines rather than legend entries, and a printed summary of every number the figure asserts. Use when the user runs /results-figure-1, or asks for a results plot, an experiment figure, a scaling or ablation chart, or asks to fix, restyle or check an existing figure in a paper's latex/figs. Not for teasers or schematics — those are teaser-figure-1 and teaser-figure-2.
disable-model-invocation: true
---
-# results-figure
+# results-figure-1
## What this covers
-The **evidence** half of figure-making: a chart that carries measurements, lives in the results section or the supplement, and is read by someone deciding whether to believe a claim. The teaser skills (`teaser-figure`, `teaser-figure-plain`) cover the other half, schematics that explain an idea. The two sets of rules conflict — decoration is necessary in a teaser and is noise here — so do not carry technique across.
+The **evidence** half of figure-making: a chart that carries measurements, lives in the results section or the supplement, and is read by someone deciding whether to believe a claim. The teaser skills (`teaser-figure-1`, `teaser-figure-2`) cover the other half, schematics that explain an idea. The two sets of rules conflict — decoration is necessary in a teaser and is noise here — so do not carry technique across.
-There is also a sibling of this skill, `results-figure-replicates`, for when per-trial data exists and the point is the spread rather than the aggregate. Read this one first; that one is this plus one idea.
+There is also a sibling of this skill, `results-figure-2`, for when per-trial data exists and the point is the spread rather than the aggregate. Read this one first; that one is this plus one idea.
## The red line
diff --git a/loom/skills/ar/figures/results-figure/example.png b/loom/skills/ar/figures/results-figure-1/example.png
similarity index 100%
rename from loom/skills/ar/figures/results-figure/example.png
rename to loom/skills/ar/figures/results-figure-1/example.png
diff --git a/loom/skills/ar/figures/results-figure/example.py b/loom/skills/ar/figures/results-figure-1/example.py
similarity index 100%
rename from loom/skills/ar/figures/results-figure/example.py
rename to loom/skills/ar/figures/results-figure-1/example.py
diff --git a/loom/skills/ar/figures/results-figure/example_data.json b/loom/skills/ar/figures/results-figure-1/example_data.json
similarity index 100%
rename from loom/skills/ar/figures/results-figure/example_data.json
rename to loom/skills/ar/figures/results-figure-1/example_data.json
diff --git a/loom/skills/ar/figures/results-figure-replicates/scripts/plot_style.py b/loom/skills/ar/figures/results-figure-1/scripts/plot_style.py
similarity index 100%
rename from loom/skills/ar/figures/results-figure-replicates/scripts/plot_style.py
rename to loom/skills/ar/figures/results-figure-1/scripts/plot_style.py
diff --git a/loom/skills/ar/figures/results-figure-replicates/SKILL.md b/loom/skills/ar/figures/results-figure-2/SKILL.md
similarity index 93%
rename from loom/skills/ar/figures/results-figure-replicates/SKILL.md
rename to loom/skills/ar/figures/results-figure-2/SKILL.md
index 2e23fd28..dd62a039 100644
--- a/loom/skills/ar/figures/results-figure-replicates/SKILL.md
+++ b/loom/skills/ar/figures/results-figure-2/SKILL.md
@@ -1,10 +1,10 @@
---
-name: results-figure-replicates
-description: Draw a results figure that shows the distribution behind every number it asserts — the aggregate in one panel, every individual run in the next, dashed reference lines carrying the published value in their own colour, and the statistics set inside the panel. The Nature-style evidence idiom, with bold lowercase panel keys and one figure-level legend. Use when the user runs /results-figure-replicates, asks to show replicates, spread, variance, per-trial or per-seed results, asks for a figure in the style of arXiv 2505.13803, or when a claim rests on a mean that hides its distribution. Read the results-figure skill first; this is that plus one idea.
+name: results-figure-2
+description: Draw a results figure that shows the distribution behind every number it asserts — the aggregate in one panel, every individual run in the next, dashed reference lines carrying the published value in their own colour, and the statistics set inside the panel. The Nature-style evidence idiom, with bold lowercase panel keys and one figure-level legend. Use when the user runs /results-figure-2, asks to show replicates, spread, variance, per-trial or per-seed results, asks for a figure in the style of arXiv 2505.13803, or when a claim rests on a mean that hides its distribution. Read the results-figure-1 skill first; this is that plus one idea.
disable-model-invocation: true
---
-# results-figure-replicates
+# results-figure-2
## The one idea
@@ -16,7 +16,7 @@ Three devices follow from that, and they are the whole style:
2. **A dashed reference line carrying the published value, with the number written in the line's own colour.** The reader never works out which line a figure belongs to, and never leaves the panel to find what is being beaten.
3. **The statistics inside the panel, right-aligned.** *n*, the spread, the step sizes, the significance count. Not three pages away in the body text.
-Everything in [results-figure](../results-figure/SKILL.md) still applies — same palette, fonts, widths, compliance. Read it first.
+Everything in [results-figure-1](../results-figure-1/SKILL.md) still applies — same palette, fonts, widths, compliance. Read it first.
## When it is worth it, and when it is not
diff --git a/loom/skills/ar/figures/results-figure-replicates/example.png b/loom/skills/ar/figures/results-figure-2/example.png
similarity index 100%
rename from loom/skills/ar/figures/results-figure-replicates/example.png
rename to loom/skills/ar/figures/results-figure-2/example.png
diff --git a/loom/skills/ar/figures/results-figure-replicates/example.py b/loom/skills/ar/figures/results-figure-2/example.py
similarity index 100%
rename from loom/skills/ar/figures/results-figure-replicates/example.py
rename to loom/skills/ar/figures/results-figure-2/example.py
diff --git a/loom/skills/ar/figures/results-figure-replicates/example_data.json b/loom/skills/ar/figures/results-figure-2/example_data.json
similarity index 100%
rename from loom/skills/ar/figures/results-figure-replicates/example_data.json
rename to loom/skills/ar/figures/results-figure-2/example_data.json
diff --git a/loom/skills/ar/figures/results-figure/scripts/plot_style.py b/loom/skills/ar/figures/results-figure-2/scripts/plot_style.py
similarity index 100%
rename from loom/skills/ar/figures/results-figure/scripts/plot_style.py
rename to loom/skills/ar/figures/results-figure-2/scripts/plot_style.py
diff --git a/loom/skills/ar/figures/results-figure-replicates/scripts/replicate_style.py b/loom/skills/ar/figures/results-figure-2/scripts/replicate_style.py
similarity index 100%
rename from loom/skills/ar/figures/results-figure-replicates/scripts/replicate_style.py
rename to loom/skills/ar/figures/results-figure-2/scripts/replicate_style.py
diff --git a/loom/skills/ar/figures/teaser-figure/SKILL.md b/loom/skills/ar/figures/teaser-figure-1/SKILL.md
similarity index 91%
rename from loom/skills/ar/figures/teaser-figure/SKILL.md
rename to loom/skills/ar/figures/teaser-figure-1/SKILL.md
index 4aaf9e79..305f51c4 100644
--- a/loom/skills/ar/figures/teaser-figure/SKILL.md
+++ b/loom/skills/ar/figures/teaser-figure-1/SKILL.md
@@ -1,10 +1,10 @@
---
-name: teaser-figure
-description: Draw a paper's page-one teaser — the three-panel problem/method/result schematic of tinted rounded boxes and arrows that explains a contribution at a glance. Emits a full-width vector PDF with embedded fonts, ready to drop into a LaTeX figure*. Use when the user runs /teaser-figure, or asks for a teaser, a Figure 1, an overview or pull figure, a graphical abstract, a pipeline or flowchart that explains how a method works, or an "Excalidraw-style" diagram for a paper.
+name: teaser-figure-1
+description: Draw a paper's page-one teaser — the three-panel problem/method/result schematic of tinted rounded boxes and arrows that explains a contribution at a glance. Emits a full-width vector PDF with embedded fonts, ready to drop into a LaTeX figure*. Use when the user runs /teaser-figure-1, or asks for a teaser, a Figure 1, an overview or pull figure, a graphical abstract, a pipeline or flowchart that explains how a method works, or an "Excalidraw-style" diagram for a paper.
disable-model-invocation: true
---
-# teaser-figure
+# teaser-figure-1
## What this makes, and what it is called
@@ -14,7 +14,7 @@ There are two kinds and they are not interchangeable. A **results teaser** is a
Ask which kind is wanted if it is not obvious. A paper whose contribution is a *number* wants a results teaser; a paper whose contribution is a *mechanism* wants this one.
-There is a second overview style in this repo, `teaser-figure-plain`: white ground, no tinted panels, the objects drawn rather than named, and a measured chart in the last panel — the SAM / FlashAttention idiom. Prefer it when the paper has something concrete to draw and a real measurement to plot, and the figure will sit under a caption that can carry the prose. Prefer this one when the contribution has no drawable object, or the figure must stand alone.
+There is a second overview style in this repo, `teaser-figure-2`: white ground, no tinted panels, the objects drawn rather than named, and a measured chart in the last panel — the SAM / FlashAttention idiom. Prefer it when the paper has something concrete to draw and a real measurement to plot, and the figure will sit under a caption that can carry the prose. Prefer this one when the contribution has no drawable object, or the figure must stand alone.
## The red line
diff --git a/loom/skills/ar/figures/teaser-figure/example.png b/loom/skills/ar/figures/teaser-figure-1/example.png
similarity index 100%
rename from loom/skills/ar/figures/teaser-figure/example.png
rename to loom/skills/ar/figures/teaser-figure-1/example.png
diff --git a/loom/skills/ar/figures/teaser-figure/example.py b/loom/skills/ar/figures/teaser-figure-1/example.py
similarity index 100%
rename from loom/skills/ar/figures/teaser-figure/example.py
rename to loom/skills/ar/figures/teaser-figure-1/example.py
diff --git a/loom/skills/ar/figures/teaser-figure-plain/scripts/overview_style.py b/loom/skills/ar/figures/teaser-figure-1/scripts/overview_style.py
similarity index 100%
rename from loom/skills/ar/figures/teaser-figure-plain/scripts/overview_style.py
rename to loom/skills/ar/figures/teaser-figure-1/scripts/overview_style.py
diff --git a/loom/skills/ar/figures/teaser-figure-plain/SKILL.md b/loom/skills/ar/figures/teaser-figure-2/SKILL.md
similarity index 95%
rename from loom/skills/ar/figures/teaser-figure-plain/SKILL.md
rename to loom/skills/ar/figures/teaser-figure-2/SKILL.md
index 1a3dbc6f..3b75bd4e 100644
--- a/loom/skills/ar/figures/teaser-figure-plain/SKILL.md
+++ b/loom/skills/ar/figures/teaser-figure-2/SKILL.md
@@ -1,16 +1,16 @@
---
-name: teaser-figure-plain
-description: Draw a paper's page-one teaser in the unadorned conference idiom — white ground, no tinted panels, the objects themselves drawn rather than named, panel names underneath as "(a) Obstacle: ...", and a real measured chart as the result panel. This is the SAM / FlashAttention look, and the second of this repo's two teaser styles. Emits a full-width vector PDF with embedded fonts. Use when the user runs /teaser-figure-plain, asks for the plain, unadorned, second or "v2" teaser style, names SAM or FlashAttention as the reference, or wants a Figure 1 whose panels draw the object instead of describing it in boxes.
+name: teaser-figure-2
+description: Draw a paper's page-one teaser in the unadorned conference idiom — white ground, no tinted panels, the objects themselves drawn rather than named, panel names underneath as "(a) Obstacle: ...", and a real measured chart as the result panel. This is the SAM / FlashAttention look, and the second of this repo's two teaser styles. Emits a full-width vector PDF with embedded fonts. Use when the user runs /teaser-figure-2, asks for the plain, unadorned, second or "v2" teaser style, names SAM or FlashAttention as the reference, or wants a Figure 1 whose panels draw the object instead of describing it in boxes.
disable-model-invocation: true
---
-# teaser-figure-plain
+# teaser-figure-2
## Which of the two styles this is
This repo has two teaser skills and they are not versions of each other.
-| | `teaser-figure` | `teaser-figure-plain` (this one) |
+| | `teaser-figure-1` | `teaser-figure-2` (this one) |
|---|---|---|
| ground | tinted panel per column, coloured title pill | white, thin vertical rules |
| panel name | pill on the top edge | `(a) Obstacle: ...` underneath |
@@ -52,7 +52,7 @@ Same skeleton as the other style — **why**, **how**, **what** — but each pan
```
- [ ] 1. Read the paper: abstract, method section, results table, and the scripts behind them
-- [ ] 2. Find the drawable object in each panel; if (a) or (b) has none, use teaser-figure instead
+- [ ] 2. Find the drawable object in each panel; if (a) or (b) has none, use teaser-figure-1 instead
- [ ] 3. Find the numbers for (c) in a result file, and note what they were averaged over
- [ ] 4. Write _overview.py against scripts/plain_style.py
- [ ] 5. Render; fix every OVERFLOW, TOO LONG and missing-glyph line
@@ -79,7 +79,7 @@ ratio(ax, x, y0, y1, "5.5×") # the factor between two bar
swatch(ax, x, y, "label", RED, dashed=False) # a mark used in the panel, named
arrow(ax, a, b, RED, rad=-0.16) # rad bends it
tag(ax, x, y, "(1+ε) × tallest fresh", GREY) # pill label for a line or arrow
-text / measure / audit / save # as in teaser-figure
+text / measure / audit / save # as in teaser-figure-1
```
`chart()` is the piece to reach for first: `series` is `[(value, tone, name, note), ...]`, `note` being the condition the bar was measured under.
diff --git a/loom/skills/ar/figures/teaser-figure-plain/example.png b/loom/skills/ar/figures/teaser-figure-2/example.png
similarity index 100%
rename from loom/skills/ar/figures/teaser-figure-plain/example.png
rename to loom/skills/ar/figures/teaser-figure-2/example.png
diff --git a/loom/skills/ar/figures/teaser-figure-plain/example.py b/loom/skills/ar/figures/teaser-figure-2/example.py
similarity index 100%
rename from loom/skills/ar/figures/teaser-figure-plain/example.py
rename to loom/skills/ar/figures/teaser-figure-2/example.py
diff --git a/loom/skills/ar/figures/teaser-figure/scripts/overview_style.py b/loom/skills/ar/figures/teaser-figure-2/scripts/overview_style.py
similarity index 100%
rename from loom/skills/ar/figures/teaser-figure/scripts/overview_style.py
rename to loom/skills/ar/figures/teaser-figure-2/scripts/overview_style.py
diff --git a/loom/skills/ar/figures/teaser-figure-plain/scripts/plain_style.py b/loom/skills/ar/figures/teaser-figure-2/scripts/plain_style.py
similarity index 100%
rename from loom/skills/ar/figures/teaser-figure-plain/scripts/plain_style.py
rename to loom/skills/ar/figures/teaser-figure-2/scripts/plain_style.py
diff --git a/loom/skills/ar/figures/teaser-figure-3/PROMPT_TEMPLATE.md b/loom/skills/ar/figures/teaser-figure-3/PROMPT_TEMPLATE.md
new file mode 100644
index 00000000..8fc1a032
--- /dev/null
+++ b/loom/skills/ar/figures/teaser-figure-3/PROMPT_TEMPLATE.md
@@ -0,0 +1,129 @@
+# Nano Banana Pipeline Prompt Templates
+
+## Initial generation
+
+Replace the bracketed fields and preserve quoted labels verbatim.
+
+```text
+Create a polished, publication-quality scientific teaser diagram for
+[PAPER / METHOD].
+
+Use the content reference for semantic structure and the style reference for
+visual inspiration. Do not copy logos or exact composition.
+
+Layout:
+- wide landscape infographic;
+- clean white background;
+- crisp vector-illustration appearance;
+- [PALETTE];
+- consistent rounded cards;
+- precise alignment and generous whitespace.
+
+Show this workflow:
+[ORDERED STAGES WITH EXACT QUOTED LABELS]
+
+Show this state bank:
+[STATE CELLS WITH EXACT LABELS]
+
+Feedback edges:
+[EXPLICIT ORIGIN -> DESTINATION FOR EACH EDGE]
+
+Place every arrow label in an opaque light background so the line never crosses
+the text. Keep text minimal and large. Spell only supplied labels. Do not
+invent paragraphs, equations, logos, citations, watermarks, or extra labels.
+Do not produce pseudo-text.
+```
+
+Suggested call:
+
+```text
+GenerateImage(
+ description=,
+ filename="-ai-v1.png",
+ reference_image_paths=[
+ "",
+ ""
+ ],
+ aspect_ratio="16:9"
+)
+```
+
+## Worked initial Prompt: streaming gate
+
+```text
+Create a polished, publication-quality scientific teaser diagram for a
+machine-learning paper titled conceptually “When to Write to Weights”.
+
+Use the references for visual inspiration and semantic structure, but do not
+copy their exact composition or logos. Wide landscape infographic on a clean
+white background, crisp vector-illustration appearance, dark navy outer loop
+and outlines, pale blue/cream/lavender/red/green stage cards, restrained
+academic palette, consistent rounded corners, precise alignment, generous
+whitespace, and strong visual hierarchy.
+
+Show a five-stage closed-loop workflow from left to right with exactly these
+large stage headings:
+“1 Item arrives”
+“2 Serve & meter”
+“3 Option gate”
+“4 Batch write”
+“5 Fresh verification”
+
+Under the workflow place a state bank titled “Measured policy state”, with
+three cells:
+“F(α) verified efficacy”
+“ĥ, ρ̂ retrieval + interference”
+“n̂ᵢ reuse forecast”
+
+Add:
+- green loop “PASS — keep in weights”;
+- red branch “ROLLBACK — external store”;
+- green feedback “update empirical state”;
+- orange arrow “price next option”.
+
+The green feedback label must have an opaque light-green background and must
+not overlap its arrow. The red rollback label must be above its arrow with an
+opaque light-red background.
+
+Keep text minimal and large. Spell only supplied labels. Do not invent prose,
+equations, logos, watermarks, citations, or extra labels. Do not produce
+garbled pseudo-text.
+```
+
+## Worked correction Prompt: V3 to V4
+
+```text
+Refine the supplied scientific workflow teaser while preserving its overall
+composition, exact five stage headings, card styling, icon quality, palette,
+typography, and central “Measured policy state” bank.
+
+Make only these routing corrections:
+
+1. “ROLLBACK — external store” must leave “5 Fresh verification”, travel
+ around the diagram without crossing text, and terminate with a red arrowhead
+ at external store / “1 Item arrives”. It must not point to Measured policy
+ state.
+2. Add a separate green arrow from “5 Fresh verification” into the right edge
+ of “Measured policy state”, labelled “update empirical state”. Put the label
+ in an opaque light-green pill above its arrow.
+3. Keep “PASS — keep in weights” as an independent top green loop.
+4. Keep “price next option” from Measured policy state to “3 Option gate”.
+5. Put the red rollback label above its arrow in an opaque light-red pill.
+
+Keep all supplied text correctly spelled. Do not add paragraphs, pseudo-text,
+new equations, logos, watermarks, or extra labels.
+```
+
+## Correction checklist
+
+Before accepting a revised image:
+
+```text
+- [ ] Wrong edge now has the required destination
+- [ ] Existing correct edges did not change
+- [ ] Exact labels remain exact
+- [ ] No new pseudo-text appeared
+- [ ] Label backgrounds cover nearby arrows
+- [ ] No text or icon is clipped
+- [ ] Output version is preserved separately
+```
diff --git a/loom/skills/ar/figures/teaser-figure-3/SKILL.md b/loom/skills/ar/figures/teaser-figure-3/SKILL.md
new file mode 100644
index 00000000..3aa08210
--- /dev/null
+++ b/loom/skills/ar/figures/teaser-figure-3/SKILL.md
@@ -0,0 +1,211 @@
+---
+name: teaser-figure-3
+description: Generate an icon-rich scientific pipeline, architecture, or closed-loop teaser with Cursor GenerateImage (the Cursor 2.4 backend was described as Nano Banana Pro), using a deterministic semantic blueprint and reference figures, then iterate after manual arrow/text review. Use when the user asks for an AI-generated paper pipeline, premium workflow infographic, architecture teaser, or Nano Banana figure. Not for quantitative result plots or equation-heavy diagrams.
+disable-model-invocation: true
+---
+
+# teaser-figure-3
+
+This skill makes a polished raster teaser with Cursor's `GenerateImage` tool.
+It is the **default AR teaser workflow** whenever Auto Research decides to
+create or refresh a teaser, Figure 1, overview, architecture, or pipeline. The
+Author applies it proactively; no user request or skill name is required.
+It is the AI-illustration alternative to:
+
+- `teaser-figure-1`: deterministic tinted boxes and arrows;
+- `teaser-figure-2`: deterministic white-ground object-and-chart layout.
+
+Use this third style when a pipeline needs expressive icons and editorial visual
+polish, while the number of exact labels and arrows remains small.
+
+Cursor does not expose an image-provider selector. Cursor 2.4 identified its
+image backend as Nano Banana Pro, but the current tool is provider-abstracted;
+do not assume the backend is permanently fixed.
+
+## The red line
+
+**The image model proposes pixels; it never decides scientific content.**
+
+Before generation, freeze:
+
+- every node and stage;
+- every directed edge and its endpoint;
+- every exact label;
+- pass/fail/rollback semantics;
+- every number or claim shown;
+- which elements may be decorative.
+
+All scientific claims come from the manuscript or result files. If the
+generated image changes an edge, invents text, drops a condition, or points an
+arrow at the wrong state, reject it even when it looks better.
+
+Do not use this skill for:
+
+- experiment curves, ablations, or statistical plots;
+- formulas that must remain editable and exact;
+- tables or dense paragraphs;
+- a figure whose labels cannot tolerate rasterization.
+
+Use `results-figure-1`, `results-figure-2`, or deterministic SVG/PDF instead.
+
+## Inputs
+
+Prepare three inputs before calling the image tool:
+
+1. **Semantic blueprint** — a table of node IDs, exact labels, and edges.
+2. **Content reference** — preferably a deterministic draft whose arrows and
+ labels are already correct.
+3. **Style reference** — one or two figures supplying palette, icon language,
+ density, and visual hierarchy.
+
+The content reference controls truth. The style reference controls appearance.
+Never ask the model to infer the graph from prose alone.
+
+## Workflow
+
+### 1. Freeze the semantic blueprint
+
+Write a compact ledger:
+
+```markdown
+| ID | Exact label | Incoming | Outgoing | Meaning |
+|---|---|---|---|---|
+| S1 | Item arrives | rollback | S2 | external-store item |
+| S2 | Serve & meter | S1, pass | S3 | observe reuse |
+| S3 | Option gate | policy state | S4 | decide whether to trial |
+| S4 | Batch write | S3 | S5 | write LoRA update |
+| S5 | Fresh verification | S4 | pass, rollback, state update | verify write |
+```
+
+List feedback edges separately. This makes endpoint errors obvious during
+review.
+
+### 2. Produce a deterministic content reference
+
+Draw a rough but semantically correct version with Python/SVG first. It may be
+plain. Its purpose is to fix:
+
+- stage order;
+- arrow direction;
+- label spelling;
+- loop endpoints;
+- relative grouping.
+
+The image model receives this alongside the style reference.
+
+### 3. Draft the generation Prompt
+
+Use [`PROMPT_TEMPLATE.md`](PROMPT_TEMPLATE.md). Specify:
+
+- wide scientific teaser;
+- exact stage headings in quotation marks;
+- exact feedback labels;
+- explicit arrow origins and destinations;
+- “minimal text” and “no pseudo-text”;
+- no logos, citations, watermarks, or invented equations;
+- opaque label backgrounds when arrows pass nearby.
+
+Avoid vague instructions such as “make the workflow correct.”
+
+### 4. Generate V1
+
+Call Cursor Agent's image tool:
+
+```text
+GenerateImage(
+ description=,
+ filename="-pipeline-ai-v1.png",
+ reference_image_paths=[
+ "",
+ ""
+ ],
+ aspect_ratio="16:9"
+)
+```
+
+`filename` cannot contain a directory. Copy the absolute output path returned
+by Cursor into the Paper Task or a stable review directory.
+
+### 5. Perform manual semantic review
+
+Open the generated image itself. Do not approve from the Prompt or tool success
+message.
+
+Check in this order:
+
+1. Are all required stages present exactly once?
+2. Are labels spelled exactly?
+3. Does every arrow terminate at the correct node?
+4. Are pass, rollback, and state-update paths distinct?
+5. Is any text hidden under an arrow?
+6. Are label boxes above their arrows?
+7. Are icons semantically compatible with their stage?
+8. Did the model invent numbers, formulas, labels, or logos?
+9. Is anything clipped at the image boundary?
+10. Is text readable at final paper width?
+
+Record semantic failures, not subjective requests like “make it nicer.”
+
+### 6. Generate a correction version
+
+Keep the first output and create `v2`, `v3`, etc. Supply the previous output and
+the deterministic content reference.
+
+The correction Prompt must say:
+
+- preserve all parts that are already correct;
+- name the wrong edge or label precisely;
+- state the required origin and destination;
+- prohibit unrelated layout or wording changes.
+
+Example:
+
+```text
+Preserve the five cards and all typography. Change only feedback routing:
+ROLLBACK must leave Fresh verification and terminate at Item arrives /
+external store. It must not point to Measured policy state. Add a separate
+green arrow from Fresh verification into Measured policy state.
+```
+
+### 7. Save provenance
+
+Keep:
+
+```text
+-ai-v1.png
+-ai-v2.png
+-selected.png
+-prompts.md
+```
+
+Never overwrite the current paper figure before the user chooses a version.
+
+## Worked example
+
+[`example.png`](example.png) is the selected V4 for the “When to Write to
+Weights” streaming gate.
+
+Its successful correction split three meanings that V3 had conflated:
+
+- `PASS — keep in weights`: top green loop;
+- `ROLLBACK — external store`: red loop back to Stage 1;
+- `update empirical state`: separate green edge into the state bank.
+
+The exact generation and correction Prompts are in
+[`PROMPT_TEMPLATE.md`](PROMPT_TEMPLATE.md).
+
+## Output quality
+
+Cursor image output is raster. At 1536 px width, a 7-inch figure is about
+219 dpi. That can be acceptable for a teaser, but it is not a native vector
+asset.
+
+If vector editing is required:
+
+- retain AI-generated icons as PNG;
+- redraw text, formulas, cards, borders, and arrows in SVG/Matplotlib;
+- export SVG/PDF with embedded fonts;
+- preserve the raster version as the visual reference.
+
+Do not automatically trace the whole PNG: text becomes paths, geometry becomes
+noisy, and the result is neither clean nor meaningfully editable.
diff --git a/loom/skills/ar/figures/teaser-figure-3/example.png b/loom/skills/ar/figures/teaser-figure-3/example.png
new file mode 100644
index 00000000..3c7af0f3
Binary files /dev/null and b/loom/skills/ar/figures/teaser-figure-3/example.png differ
diff --git a/loom/skills/dev/loom-hot-restart/SKILL.md b/loom/skills/dev/loom-hot-restart/SKILL.md
new file mode 100644
index 00000000..2aa8674c
--- /dev/null
+++ b/loom/skills/dev/loom-hot-restart/SKILL.md
@@ -0,0 +1,166 @@
+---
+name: loom-hot-restart
+description: Restarts a running Loom web service from an updated source checkout while preserving its authentication environment, disk-backed tasks, tmux agents, and existing Turbogate public URL. Use when the user asks to hot-update, hot-reload, restart, or deploy Loom without changing its domain or token.
+disable-model-invocation: true
+---
+
+# Loom Hot Restart
+
+This is a **controlled process replacement**, not in-process Python hot
+loading. Expect a short local-port outage while the public tunnel process stays
+alive and resumes forwarding to the new Loom process.
+
+Linux only: the helper uses `/proc`, POSIX signals, process groups, and `fork`.
+
+## Guarantees
+
+When all checks pass, the helper preserves:
+
+- `LOOM_WEB_AUTH_TOKEN` and `TOGETHER_API_KEY` byte-for-byte;
+- the existing Turbogate process and public URL;
+- the same port, command-line options, and project selection;
+- `.RUD` task state and Git repositories on disk;
+- independent tmux Agent sessions.
+
+It verifies both the local and public `/api/projects` endpoints before
+reporting success.
+
+## Hard safety rules
+
+1. Never print, log, pass on the command line, or write either secret.
+2. Never start a second Loom before identifying the one existing process on the
+ target port.
+3. Never gracefully stop a Loom whose tunnel must survive: its shutdown handler
+ may close Turbogate and release the domain.
+4. Refuse while paper mining, idea generation, or Reviewer jobs are running.
+ Those are server subprocesses and are not resumable. Author tmux rounds are
+ safe and may continue.
+5. Keep the existing Turbogate process. A newly launched tunnel may receive a
+ different domain.
+6. Run `--dry-run` first and inspect the JSON before executing.
+
+## Quick start
+
+From the Loom checkout:
+
+```bash
+python loom/skills/dev/loom-hot-restart/scripts/hot_restart.py \
+ --port 8766 \
+ --source /absolute/path/to/updated/Loom \
+ --dry-run
+```
+
+Then execute:
+
+```bash
+python loom/skills/dev/loom-hot-restart/scripts/hot_restart.py \
+ --port 8766 \
+ --source /absolute/path/to/updated/Loom
+```
+
+To also retire an obsolete Loom instance:
+
+```bash
+python loom/skills/dev/loom-hot-restart/scripts/hot_restart.py \
+ --port 8766 \
+ --source /absolute/path/to/updated/Loom \
+ --stop-port 8765
+```
+
+If the running Loom no longer exposes `/api/turbogate`, supply the known
+existing URL explicitly:
+
+```bash
+python loom/skills/dev/loom-hot-restart/scripts/hot_restart.py \
+ --port 8766 \
+ --source /absolute/path/to/updated/Loom \
+ --public-url https://p-example.gate.together-turbo.com
+```
+
+The helper writes the non-secret URL and process identifiers to
+`/.RUD/hot-restart-.json` for the next restart.
+
+## Workflow
+
+### 1. Preflight
+
+Run the helper with `--dry-run`. Confirm:
+
+- exactly one Loom process owns the target port;
+- the reported source checkout is the intended updated checkout;
+- the tunnel PID and public URL match the current deployment;
+- `active_one_shot_jobs` is empty;
+- the launch command retains required flags such as `--projects` or `--skills`.
+
+The dry-run output contains no secret values.
+
+### 2. Execute
+
+Run the same command without `--dry-run`.
+
+The helper:
+
+1. reads the old Loom environment in memory;
+2. optionally stops obsolete Loom ports normally;
+3. keeps a reader attached to the Turbogate stdout pipe;
+4. replaces the target Loom without invoking its tunnel cleanup;
+5. launches `source/.venv/bin/python -m loom web` with the old arguments and
+ environment;
+6. checks the local API;
+7. checks the same public URL;
+8. compares secret fingerprints;
+9. confirms the original tunnel PID is still alive.
+
+### 3. Validate the result
+
+Require all of these JSON fields:
+
+```json
+{
+ "ok": true,
+ "local_health": "ok",
+ "public_health": "ok",
+ "secrets_unchanged": true
+}
+```
+
+Also verify the new PID starts from the requested checkout:
+
+```bash
+readlink -f /proc//cwd
+ps -p , -o pid,ppid,sid,lstart,args
+```
+
+### 4. Update already-running Author rounds
+
+Server-side code, Readiness Gates, and Reviewers use the new implementation
+immediately. An Author Prompt sent before the restart still contains its old
+instructions.
+
+If renamed Skills or new Author rules must apply in the current round:
+
+1. inspect the task pane;
+2. do not interrupt an Agent while it is working;
+3. when idle, send a short migration note through Loom's
+ `POST /api/tasks//claude/send`;
+4. tell it to continue the same round rather than restarting work.
+
+Future round prompts are built from the updated source automatically.
+
+## Failure handling
+
+- If preflight reports active one-shot jobs, wait for them. Use
+ `--allow-active-jobs` only when losing those calls is intentional.
+- If the new local API fails, inspect `/.RUD/loom-.log` or the
+ original terminal.
+- If the public check fails but the tunnel PID is alive, restore the local Loom
+ listener on the same port before touching Turbogate.
+- Never kill and recreate the tunnel merely to fix the Loom process.
+- Domain preservation only applies while the existing tunnel process remains
+ alive. If Turbogate itself exits, this workflow cannot guarantee recovery of
+ the same assigned domain.
+
+## Helper
+
+Execute [`scripts/hot_restart.py`](scripts/hot_restart.py); do not copy its
+secret-handling or process-control steps into ad-hoc shell commands.
diff --git a/loom/skills/dev/loom-hot-restart/scripts/hot_restart.py b/loom/skills/dev/loom-hot-restart/scripts/hot_restart.py
new file mode 100644
index 00000000..4b3042bc
--- /dev/null
+++ b/loom/skills/dev/loom-hot-restart/scripts/hot_restart.py
@@ -0,0 +1,512 @@
+#!/usr/bin/env python3
+"""Controlled Loom restart that preserves auth, tasks, tmux, and Turbogate.
+
+Linux only: process discovery and environment transfer use /proc.
+Secrets are copied in memory and never printed or written to disk.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import signal
+import socket
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+SECRET_NAMES = ("LOOM_WEB_AUTH_TOKEN", "TOGETHER_API_KEY")
+
+
+@dataclass(frozen=True)
+class Process:
+ pid: int
+ ppid: int
+ argv: tuple[str, ...]
+ cwd: Path
+ env: dict[str, str]
+
+
+def _proc_alive(pid: int) -> bool:
+ try:
+ os.kill(pid, 0)
+ return True
+ except ProcessLookupError:
+ return False
+
+
+def _read_process(pid: int) -> Process | None:
+ root = Path("/proc") / str(pid)
+ try:
+ argv = tuple(
+ part.decode("utf-8", "surrogateescape")
+ for part in (root / "cmdline").read_bytes().split(b"\0")
+ if part
+ )
+ env: dict[str, str] = {}
+ for entry in (root / "environ").read_bytes().split(b"\0"):
+ if b"=" not in entry:
+ continue
+ key, value = entry.split(b"=", 1)
+ env[key.decode("utf-8", "surrogateescape")] = value.decode(
+ "utf-8", "surrogateescape"
+ )
+ ppid = 0
+ for line in (root / "status").read_text().splitlines():
+ if line.startswith("PPid:"):
+ ppid = int(line.split(":", 1)[1].strip())
+ break
+ return Process(
+ pid=pid,
+ ppid=ppid,
+ argv=argv,
+ cwd=(root / "cwd").resolve(),
+ env=env,
+ )
+ except (FileNotFoundError, PermissionError, ProcessLookupError, ValueError):
+ return None
+
+
+def _processes() -> list[Process]:
+ out: list[Process] = []
+ for item in Path("/proc").iterdir():
+ if item.name.isdigit():
+ process = _read_process(int(item.name))
+ if process is not None and process.argv:
+ out.append(process)
+ return out
+
+
+def _option(argv: tuple[str, ...], name: str) -> str:
+ for index, value in enumerate(argv):
+ if value == name and index + 1 < len(argv):
+ return argv[index + 1]
+ if value.startswith(name + "="):
+ return value.split("=", 1)[1]
+ return ""
+
+
+def find_loom(port: int) -> Process:
+ matches = []
+ for process in _processes():
+ args = process.argv
+ joined = "\0".join(args)
+ is_loom = (
+ "\0-m\0loom\0web" in "\0" + joined
+ or (Path(args[0]).name == "loom" and len(args) > 1 and args[1] == "web")
+ )
+ if is_loom and _option(args, "--port") == str(port):
+ matches.append(process)
+ if len(matches) != 1:
+ raise RuntimeError(
+ f"expected exactly one Loom process on port {port}, found {len(matches)}"
+ )
+ return matches[0]
+
+
+def find_turbogate(port: int) -> Process | None:
+ matches = []
+ for process in _processes():
+ args = process.argv
+ if (
+ Path(args[0]).name == "turbogate"
+ and "http" in args
+ and str(port) in args
+ and "--public" in args
+ ):
+ matches.append(process)
+ if len(matches) > 1:
+ raise RuntimeError(f"multiple Turbogate processes target port {port}")
+ return matches[0] if matches else None
+
+
+def _api_json(url: str, token: str, *, timeout: float = 5.0) -> dict[str, Any]:
+ request = urllib.request.Request(
+ url, headers={"Authorization": "Bearer " + token}
+ )
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ payload = json.load(response)
+ return payload if isinstance(payload, dict) else {"value": payload}
+
+
+def _wait_api(
+ url: str, token: str, *, timeout: float, interval: float = 0.25
+) -> dict[str, Any]:
+ deadline = time.monotonic() + timeout
+ last_error: Exception | None = None
+ while time.monotonic() < deadline:
+ try:
+ return _api_json(url, token, timeout=min(5.0, timeout))
+ except Exception as exc: # noqa: BLE001 - retry boundary
+ last_error = exc
+ time.sleep(interval)
+ name = type(last_error).__name__ if last_error else "unknown error"
+ raise RuntimeError(f"{url} did not become healthy ({name})")
+
+
+def _active_one_shot_jobs(port: int, token: str) -> list[str]:
+ """Reviewer/idea/mining jobs die with the server; tmux author loops survive."""
+ base = f"http://127.0.0.1:{port}"
+ try:
+ payload = _api_json(base + "/api/projects", token)
+ except Exception:
+ return ["could not inspect active jobs"]
+ projects = payload.get("projects", payload.get("value", []))
+ if not isinstance(projects, list):
+ return ["could not parse the project list"]
+ active: list[str] = []
+ for project in projects:
+ if not isinstance(project, dict) or not project.get("id"):
+ continue
+ project_id = str(project["id"])
+ try:
+ tasks = _api_json(
+ base
+ + "/api/tasks?project="
+ + urllib.parse.quote(project_id),
+ token,
+ ).get("tasks", [])
+ except Exception:
+ continue
+ for task in tasks if isinstance(tasks, list) else []:
+ if not isinstance(task, dict) or str(task.get("kind", "")).lower() not in (
+ "ar",
+ "aris",
+ ):
+ continue
+ slug = str(task.get("slug") or "")
+ try:
+ ar_payload = _api_json(
+ base
+ + "/api/tasks/"
+ + urllib.parse.quote(slug)
+ + "/ar?project="
+ + urllib.parse.quote(project_id),
+ token,
+ )
+ except Exception:
+ continue
+ state = ar_payload.get("state") or {}
+ for job in ("papers", "ideas", "review"):
+ if str(state.get(f"{job}_status") or "") == "running":
+ active.append(f"{project_id}/{slug}: {job}")
+ return active
+
+
+def _secret_fingerprints(env: dict[str, str]) -> dict[str, str]:
+ return {
+ name: hashlib.sha256(env.get(name, "").encode()).hexdigest()
+ for name in SECRET_NAMES
+ }
+
+
+def _wait_dead(pid: int, timeout: float) -> bool:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if not _proc_alive(pid):
+ return True
+ time.sleep(0.1)
+ return not _proc_alive(pid)
+
+
+def _port_open(port: int) -> bool:
+ with socket.socket() as sock:
+ sock.settimeout(0.3)
+ return sock.connect_ex(("127.0.0.1", port)) == 0
+
+
+def _stop_group(process: Process, *, graceful: bool) -> None:
+ sig = signal.SIGTERM if graceful else signal.SIGKILL
+ os.kill(process.pid, sig)
+ if _wait_dead(process.pid, 12.0 if graceful else 5.0):
+ return
+ os.kill(process.pid, signal.SIGKILL)
+ if not _wait_dead(process.pid, 5.0):
+ raise RuntimeError(f"process {process.pid} did not exit")
+
+
+def _stop_obsolete_port(port: int) -> None:
+ process = find_loom(port)
+ tunnel = find_turbogate(port)
+ _stop_group(process, graceful=True)
+ if tunnel is not None and _proc_alive(tunnel.pid):
+ try:
+ os.killpg(tunnel.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ if not _wait_dead(tunnel.pid, 5.0):
+ try:
+ os.killpg(tunnel.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ if _port_open(port):
+ raise RuntimeError(f"obsolete Loom port {port} is still listening")
+
+
+def _preserve_tunnel_pipe(parent: Process, tunnel: Process) -> int | None:
+ """Keep the tunnel's stdout readable after killing its original parent."""
+ if tunnel.ppid != parent.pid:
+ return None # It is already detached and has a surviving reader.
+ try:
+ target = os.readlink(f"/proc/{tunnel.pid}/fd/1")
+ except OSError as exc:
+ raise RuntimeError(f"cannot inspect Turbogate stdout: {exc}") from exc
+ read_fd: int | None = None
+ for item in (Path("/proc") / str(parent.pid) / "fd").iterdir():
+ try:
+ if os.readlink(item) == target:
+ read_fd = os.open(item, os.O_RDONLY)
+ break
+ except OSError:
+ continue
+ if read_fd is None:
+ raise RuntimeError("cannot preserve the Turbogate stdout pipe")
+ child = os.fork()
+ if child == 0:
+ try:
+ os.setsid()
+ os.set_blocking(read_fd, True)
+ while os.read(read_fd, 65536):
+ pass
+ except Exception:
+ pass
+ finally:
+ try:
+ os.close(read_fd)
+ except OSError:
+ pass
+ os._exit(0)
+ os.close(read_fd)
+ return child
+
+
+def _public_url(port: int, token: str, supplied: str) -> str:
+ if supplied:
+ return supplied.rstrip("/")
+ try:
+ status = _api_json(
+ f"http://127.0.0.1:{port}/api/turbogate", token
+ )
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ return ""
+ raise
+ return str(status.get("url") or "").rstrip("/")
+
+
+def _launch_command(old: Process, source: Path) -> tuple[list[str], Path]:
+ python = source / ".venv" / "bin" / "python"
+ if not python.is_file():
+ raise RuntimeError(f"updated interpreter does not exist: {python}")
+ argv = list(old.argv)
+ if len(argv) >= 4 and argv[1:4] == ["-m", "loom", "web"]:
+ argv[0] = str(python)
+ elif len(argv) >= 2 and Path(argv[0]).name == "loom" and argv[1] == "web":
+ argv = [str(python), "-m", "loom", *argv[1:]]
+ else:
+ raise RuntimeError("unsupported Loom command shape")
+ return argv, source
+
+
+def _output_stream(old: Process, source: Path, port: int):
+ try:
+ target = os.readlink(f"/proc/{old.pid}/fd/1")
+ except OSError:
+ target = ""
+ if target.startswith("/dev/") and Path(target).exists():
+ return open(target, "a", buffering=1)
+ log = source / ".RUD" / f"loom-{port}.log"
+ log.parent.mkdir(parents=True, exist_ok=True)
+ return log.open("a", buffering=1)
+
+
+def restart(args: argparse.Namespace) -> dict[str, Any]:
+ source = args.source.expanduser().resolve()
+ if not (source / "loom").is_dir():
+ raise RuntimeError(f"not a Loom source checkout: {source}")
+ old = find_loom(args.port)
+ token = old.env.get("LOOM_WEB_AUTH_TOKEN", "")
+ if not token:
+ raise RuntimeError("running Loom has no LOOM_WEB_AUTH_TOKEN")
+ if os.geteuid() != Path(f"/proc/{old.pid}").stat().st_uid:
+ raise RuntimeError("running Loom belongs to another user")
+
+ active = _active_one_shot_jobs(args.port, token)
+ if active and not args.allow_active_jobs:
+ raise RuntimeError(
+ "refusing to interrupt non-resumable jobs: "
+ + ", ".join(active)
+ + " (wait, or pass --allow-active-jobs)"
+ )
+
+ tunnel = find_turbogate(args.port) if args.preserve_tunnel else None
+ public_url = _public_url(args.port, token, args.public_url)
+ if args.preserve_tunnel:
+ if tunnel is None:
+ raise RuntimeError("no Turbogate process found for the target port")
+ if not public_url:
+ raise RuntimeError(
+ "public URL is unknown; pass --public-url so identity can be verified"
+ )
+ for name in SECRET_NAMES:
+ if not old.env.get(name):
+ raise RuntimeError(f"running Loom is missing {name}")
+
+ command, cwd = _launch_command(old, source)
+ result: dict[str, Any] = {
+ "old_pid": old.pid,
+ "port": args.port,
+ "source": str(source),
+ "public_url": public_url,
+ "active_one_shot_jobs": active,
+ "dry_run": bool(args.dry_run),
+ }
+ if args.dry_run:
+ result["tunnel_pid"] = tunnel.pid if tunnel else None
+ result["command"] = command
+ return result
+
+ for port in args.stop_port:
+ if port == args.port:
+ raise RuntimeError("--stop-port cannot equal --port")
+ _stop_obsolete_port(port)
+
+ before = _secret_fingerprints(old.env)
+ drainer_pid = (
+ _preserve_tunnel_pipe(old, tunnel)
+ if tunnel is not None
+ else None
+ )
+ _stop_group(old, graceful=not bool(tunnel))
+ if tunnel is not None and not _proc_alive(tunnel.pid):
+ raise RuntimeError("Turbogate exited while replacing Loom")
+
+ deadline = time.monotonic() + 8.0
+ while _port_open(args.port) and time.monotonic() < deadline:
+ time.sleep(0.1)
+ if _port_open(args.port):
+ raise RuntimeError(f"port {args.port} did not become free")
+
+ output = _output_stream(old, source, args.port)
+ try:
+ new = subprocess.Popen(
+ command,
+ cwd=str(cwd),
+ env=old.env,
+ stdin=subprocess.DEVNULL,
+ stdout=output,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ close_fds=True,
+ )
+ finally:
+ output.close()
+
+ try:
+ _wait_api(
+ f"http://127.0.0.1:{args.port}/api/projects",
+ token,
+ timeout=args.startup_timeout,
+ )
+ if public_url:
+ _wait_api(
+ public_url + "/api/projects",
+ token,
+ timeout=args.startup_timeout,
+ )
+ except Exception:
+ if _proc_alive(new.pid):
+ _stop_group(_read_process(new.pid) or old, graceful=True)
+ raise
+
+ current = _read_process(new.pid)
+ if current is None:
+ raise RuntimeError("restarted Loom disappeared after health check")
+ after = _secret_fingerprints(current.env)
+ if before != after:
+ raise RuntimeError("secret fingerprints changed during restart")
+ if tunnel is not None and not _proc_alive(tunnel.pid):
+ raise RuntimeError("Turbogate disappeared after health check")
+
+ result.update(
+ {
+ "new_pid": new.pid,
+ "tunnel_pid": tunnel.pid if tunnel else None,
+ "drainer_pid": drainer_pid,
+ "local_health": "ok",
+ "public_health": "ok" if public_url else "not-requested",
+ "secrets_unchanged": True,
+ }
+ )
+ state_file = source / ".RUD" / f"hot-restart-{args.port}.json"
+ state_file.parent.mkdir(parents=True, exist_ok=True)
+ state_file.write_text(
+ json.dumps(
+ {
+ "port": args.port,
+ "public_url": public_url,
+ "last_pid": new.pid,
+ "tunnel_pid": tunnel.pid if tunnel else None,
+ "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ },
+ indent=2,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ return result
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description=(
+ "Restart Loom from an updated checkout while preserving its "
+ "environment, tmux tasks, and optional public tunnel."
+ )
+ )
+ parser.add_argument("--port", type=int, required=True)
+ parser.add_argument("--source", type=Path, required=True)
+ parser.add_argument(
+ "--stop-port",
+ type=int,
+ action="append",
+ default=[],
+ help="obsolete Loom port to stop completely; repeat as needed",
+ )
+ parser.add_argument(
+ "--public-url",
+ default="",
+ help="expected existing Turbogate URL when the old API cannot report it",
+ )
+ parser.add_argument(
+ "--preserve-tunnel",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ )
+ parser.add_argument("--allow-active-jobs", action="store_true")
+ parser.add_argument("--startup-timeout", type=float, default=45.0)
+ parser.add_argument("--dry-run", action="store_true")
+ return parser.parse_args()
+
+
+def main() -> int:
+ try:
+ result = restart(parse_args())
+ except Exception as exc: # noqa: BLE001 - CLI boundary
+ print(json.dumps({"ok": False, "error": str(exc)}))
+ return 1
+ print(json.dumps({"ok": True, **result}, ensure_ascii=False))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/loom/web.py b/loom/web.py
index 57ba3184..3842271a 100644
--- a/loom/web.py
+++ b/loom/web.py
@@ -3777,10 +3777,10 @@ def _ar_run_async(fn, *args: Any) -> None:
def _ar_headless_model(meta: Any) -> str:
- """Model for headless AR calls.
+ """Claude model for headless Studio idea generation.
- These go through ``claude -p``, so a task configured for Cursor or Codex
- cannot lend its model id; fall back to the Claude default in that case.
+ Idea generation still goes through ``claude -p``. Paper reviews use the
+ fixed Cursor PDF reviewer panel defined in ``ar_task.py``.
"""
if meta is not None and normalize_agent(getattr(meta, "agent", "")) == AGENT_CLAUDE:
model = str(getattr(meta, "interview_model", "") or "").strip()
@@ -3825,6 +3825,115 @@ def _ar_logger(root: Path, slug: str, job: str, *, reset: bool = True):
return lambda line: ar.append_job_log(path, line)
+def _ar_reviewer_slug(model: str) -> str:
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", str(model or "")).strip("-._")
+ return slug or "reviewer"
+
+
+def _ar_store_panel_reviews(
+ root: Path,
+ slug: str,
+ n: int,
+ reviewers: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """Persist each independent review and return compact state metadata."""
+ directory = ar.round_dir(root, slug, n)
+ directory.mkdir(parents=True, exist_ok=True)
+ stored: list[dict[str, Any]] = []
+ for item in reviewers:
+ model = str(item.get("model") or "reviewer")
+ text = str(item.get("review") or "").strip()
+ path = directory / f"review-{_ar_reviewer_slug(model)}.md"
+ if text:
+ path.write_text(text + "\n", encoding="utf-8")
+ metadata = {
+ key: item.get(key)
+ for key in ("model", "scores", "headline", "duration_seconds", "cost")
+ }
+ metadata["path"] = str(path) if text else ""
+ stored.append(metadata)
+ return stored
+
+
+_PANEL_REVIEW_RE = re.compile(
+ r"(?ms)^# Reviewer: `([^`]+)`\s*\n(.*?)(?=^\s*---\s*$|\Z)"
+)
+
+
+def _ar_review_payload(root: Path, slug: str, n: int) -> dict[str, Any] | None:
+ """Review API payload with every model's full report.
+
+ New rounds read per-model files. Existing panel rounds are recovered from
+ the combined review.md, and old single-model rounds remain readable.
+ """
+ combined_path = ar.review_note_path(root, slug, n)
+ if not combined_path.is_file():
+ return None
+ combined = _ar_read_text(combined_path)
+ state = ar.read_ar_state(root, slug)
+ rec = ar.round_record(state, n) or {}
+ review = rec.get("review") if isinstance(rec.get("review"), dict) else {}
+ metadata = (
+ review.get("reviewers")
+ if isinstance(review.get("reviewers"), list)
+ else []
+ )
+ parsed = {
+ model: body.strip()
+ for model, body in _PANEL_REVIEW_RE.findall(combined)
+ }
+ directory = ar.round_dir(root, slug, n).resolve()
+ reviewers: list[dict[str, Any]] = []
+ for item in metadata:
+ if not isinstance(item, dict):
+ continue
+ model = str(item.get("model") or "")
+ body = ""
+ path_value = str(item.get("path") or "")
+ if path_value:
+ candidate: Path | None = Path(path_value).expanduser().resolve()
+ try:
+ candidate.relative_to(directory)
+ except ValueError:
+ candidate = None
+ if candidate is not None and candidate.is_file():
+ body = _ar_read_text(candidate)
+ if not body:
+ body = parsed.get(model, "")
+ reviewers.append({**item, "review": body})
+
+ if not reviewers and parsed:
+ for model, body in parsed.items():
+ scores = ar.parse_review_scores(body)
+ reviewers.append(
+ {
+ "model": model,
+ "scores": scores,
+ "headline": ar.review_headline(scores),
+ "review": body,
+ }
+ )
+ if not reviewers:
+ model = str(review.get("model") or "")
+ reviewers = [
+ {
+ "model": model or "reviewer",
+ "scores": review.get("scores") or {},
+ "headline": review.get("headline") or "",
+ "review": combined,
+ }
+ ]
+ return {
+ "ok": True,
+ "round": n,
+ "review": combined,
+ "scores": review.get("scores") or {},
+ "headline": review.get("headline") or "",
+ "deciding_model": str(review.get("deciding_model") or ""),
+ "reviewers": reviewers,
+ }
+
+
def _ar_mine_job(root: Path, slug: str, limit: int, venue_only: bool) -> None:
state = ar.read_ar_state(root, slug)
log = _ar_logger(root, slug, ar.JOB_PAPERS)
@@ -3920,7 +4029,7 @@ def _ar_link_job(root: Path, slug: str, model: str) -> None:
print(f"[ar] {slug}: linked {res.get('linked')} idea(s) to prior work", flush=True)
-def _ar_review_job(root: Path, slug: str, model: str) -> None:
+def _ar_review_job(root: Path, slug: str) -> None:
"""One out-of-band review, triggered from the panel rather than the loop."""
state = ar.read_ar_state(root, slug)
paper_dir = ar.paper_root(root, slug)
@@ -3932,23 +4041,41 @@ def _ar_review_job(root: Path, slug: str, model: str) -> None:
if build.get("ok")
else f"PDF build failed: {build.get('error')}"
)
- author_note = _ar_read_text(ar.author_note_path(root, slug, n))
res = ar.run_reviewer(
paper_dir,
ar.ar_skill_text(ar.SKILL_REVIEWER),
venue=str(state.get("venue") or ar.DEFAULT_VENUE),
- idea=state.get("idea") or {},
round_n=max(1, n),
- author_note=author_note,
build=build,
- model=model,
+ models=ar.CURSOR_REVIEWER_MODELS,
on_line=log,
)
if not res.get("ok"):
log(f"failed: {res.get('error')}")
- ar.update_ar_state(
- root, slug, review_status="error", review_error=str(res.get("error") or "")
- )
+ readiness = res.get("readiness")
+ if isinstance(readiness, dict):
+ report_path = ar.round_dir(root, slug, n) / "readiness.md"
+ report_path.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ report_path.write_text(
+ ar.review_readiness_markdown(readiness), encoding="utf-8"
+ )
+ readiness["report_path"] = str(report_path)
+ except OSError:
+ pass
+ state = ar.read_ar_state(root, slug)
+ rec = ar.ensure_round(state, n)
+ rec["readiness"] = readiness
+ state["review_status"] = "error"
+ state["review_error"] = str(res.get("error") or "")
+ ar.write_ar_state(root, slug, state)
+ else:
+ ar.update_ar_state(
+ root,
+ slug,
+ review_status="error",
+ review_error=str(res.get("error") or ""),
+ )
return
path = ar.review_note_path(root, slug, n)
path.parent.mkdir(parents=True, exist_ok=True)
@@ -3959,15 +4086,31 @@ def _ar_review_job(root: Path, slug: str, model: str) -> None:
return
state = ar.read_ar_state(root, slug)
rec = ar.ensure_round(state, n)
+ try:
+ stored_reviewers = _ar_store_panel_reviews(
+ root, slug, n, list(res.get("reviewers") or [])
+ )
+ except OSError as exc:
+ ar.update_ar_state(
+ root, slug, review_status="error", review_error=str(exc)
+ )
+ return
rec["review"] = {
"created_at": _iso_now(),
- "model": model,
+ "model": ar.CURSOR_REVIEWER_PANEL,
+ "models": res.get("models") or list(ar.CURSOR_REVIEWER_MODELS),
"path": str(path),
"scores": res.get("scores") or {},
"headline": res.get("headline") or "",
+ "deciding_model": res.get("deciding_model") or "",
+ "input_pdf": res.get("input_pdf") or str(paper_dir / "main.pdf"),
+ "reviewers": stored_reviewers,
}
state["review_status"] = "done"
state["review_error"] = ""
+ state["cost_usd"] = round(
+ float(state.get("cost_usd") or 0.0) + float(res.get("cost") or 0.0), 4
+ )
ar.write_ar_state(root, slug, state)
@@ -4018,7 +4161,8 @@ def _ar_spawn_children(
custom_direction=str(state.get("custom_direction") or ""),
max_rounds=state.get("max_rounds", ar.DEFAULT_MAX_ROUNDS),
author_model=(parent.interview_model if parent else ""),
- reviewer_model=_ar_headless_model(parent),
+ reviewer_model=ar.CURSOR_REVIEWER_PANEL,
+ reviewer_models=ar.CURSOR_REVIEWER_MODELS,
)
paper_state["paper_dir"] = str(paper_dir)
ar.write_ar_state(root, child.slug, paper_state)
@@ -4219,6 +4363,17 @@ def _tick_loop(self, state: dict[str, Any]) -> None:
self._start_round(state, n + 1)
return
+ readiness = rec.get("readiness")
+ if isinstance(readiness, dict) and not readiness.get("ready"):
+ # A failed completion note is archived. Wait for the author to
+ # write a new one after receiving the deterministic failure list.
+ note = ar.author_note_path(self.project_root, self.slug, n)
+ if note.is_file():
+ self._close_round(state, n, note)
+ elif not readiness.get("repair_prompt_sent_at"):
+ self._send_readiness_prompt(state, n)
+ return
+
# The author's note is the authoritative end-of-round signal, so check
# it before the prompt bookkeeping: a round driven by hand, or one whose
# prompt failed to paste and was sent another way, still closes.
@@ -4294,35 +4449,126 @@ def _send_round_prompt(self, state: dict[str, Any], n: int) -> None:
self._save(state)
self._note(f"round {n} prompt sent to the agent pane")
+ def _send_readiness_prompt(self, state: dict[str, Any], n: int) -> None:
+ rec = ar.round_record(state, n) or {}
+ readiness = (
+ rec.get("readiness")
+ if isinstance(rec.get("readiness"), dict)
+ else {}
+ )
+ report_value = str(readiness.get("report_path") or "")
+ prompt = ar.author_readiness_repair_prompt(
+ task_root(self.project_root, self.slug),
+ self._paper_dir(),
+ state,
+ n,
+ readiness,
+ report_path=Path(report_value) if report_value else None,
+ )
+ ok, err = self._paste(prompt)
+ if not ok:
+ self.last_error = err
+ return
+ self.last_error = ""
+ state = self._state()
+ rec = ar.ensure_round(state, n)
+ latest = dict(rec.get("readiness") or {})
+ latest["repair_prompt_sent_at"] = _iso_now()
+ rec["readiness"] = latest
+ self._save(state)
+ self._note(f"round {n} readiness failures returned to the author")
+
def _close_round(self, state: dict[str, Any], n: int, note: Path) -> None:
- self._note(f"round {n} author finished - building and reviewing")
+ self._note(f"round {n} author finished - checking submission readiness")
build = self._build()
- author_text = _ar_read_text(note)
state = self._state()
rec = ar.ensure_round(state, n)
+ readiness = ar.review_readiness(
+ self._paper_dir(),
+ venue=str(state.get("venue") or ar.DEFAULT_VENUE),
+ build=build,
+ )
+ attempts = rec.setdefault("readiness_attempts", [])
+ attempt_n = len(attempts) + 1
+ report_path = (
+ ar.round_dir(self.project_root, self.slug, n)
+ / (
+ "readiness.md"
+ if readiness.get("ready")
+ else f"readiness-attempt-{attempt_n:02d}.md"
+ )
+ )
+ try:
+ report_path.write_text(
+ ar.review_readiness_markdown(readiness), encoding="utf-8"
+ )
+ except OSError as exc:
+ self.last_error = f"could not write readiness report: {exc}"
+ rec["review_error"] = self.last_error
+ self._save(state)
+ self.stop()
+ return
+ readiness["report_path"] = str(report_path)
+
+ if not readiness.get("ready"):
+ attempt_note = (
+ ar.round_dir(self.project_root, self.slug, n)
+ / f"author-attempt-{attempt_n:02d}.md"
+ )
+ summary = _ar_read_head(note)
+ attempts.append(
+ {
+ "attempt": attempt_n,
+ "ended_at": _iso_now(),
+ "note": str(attempt_note),
+ "summary": summary,
+ "report": str(report_path),
+ "failed": readiness.get("failed") or [],
+ }
+ )
+ rec["readiness"] = readiness
+ rec.pop("author", None)
+ rec.pop("review_error", None)
+ self._save(state)
+ # Persist the blocked state before consuming author.md. If Loom
+ # dies between these operations, restart sees the failed gate and
+ # safely rechecks the still-present note instead of wedging.
+ try:
+ note.replace(attempt_note)
+ except OSError as exc:
+ self.last_error = f"could not archive blocked author note: {exc}"
+ state = self._state()
+ rec = ar.ensure_round(state, n)
+ rec["review_error"] = self.last_error
+ self._save(state)
+ self.stop()
+ return
+ self._note(
+ f"round {n} review blocked by {len(readiness.get('failed') or [])} "
+ "readiness check(s)"
+ )
+ self._send_readiness_prompt(self._state(), n)
+ return
+
rec["author"] = {
"ended_at": _iso_now(),
"note": str(note),
"summary": _ar_read_head(note),
}
+ rec["readiness"] = readiness
+ rec.pop("review_error", None)
self._save(state)
+ self._note(f"round {n} readiness passed - starting reviewer panel")
- base_model = str(state.get("reviewer_model") or "") or agent_default_model(
- AGENT_CLAUDE
- )
- reviewer_model = ar.reviewer_model_for(state, base_model, n)
- if reviewer_model != base_model:
- self._note(f"round {n} plateaued - reviewing with {reviewer_model} instead")
result = ar.run_reviewer(
self._paper_dir(),
ar.ar_skill_text(ar.SKILL_REVIEWER),
venue=str(state.get("venue") or ar.DEFAULT_VENUE),
- idea=state.get("idea") or {},
round_n=n,
- author_note=author_text,
build=build,
- model=reviewer_model,
+ readiness=readiness,
+ models=ar.CURSOR_REVIEWER_MODELS,
on_line=_ar_logger(self.project_root, self.slug, ar.JOB_REVIEW),
)
state = self._state()
@@ -4339,6 +4585,12 @@ def _close_round(self, state: dict[str, Any], n: int, note: Path) -> None:
review_path.parent.mkdir(parents=True, exist_ok=True)
try:
review_path.write_text(str(result.get("review") or ""), encoding="utf-8")
+ stored_reviewers = _ar_store_panel_reviews(
+ self.project_root,
+ self.slug,
+ n,
+ list(result.get("reviewers") or []),
+ )
except OSError as exc:
self.last_error = f"could not write review: {exc}"
self._save(state)
@@ -4346,15 +4598,20 @@ def _close_round(self, state: dict[str, Any], n: int, note: Path) -> None:
rec["review"] = {
"created_at": _iso_now(),
- "model": reviewer_model,
+ "model": ar.CURSOR_REVIEWER_PANEL,
+ "models": result.get("models") or list(ar.CURSOR_REVIEWER_MODELS),
"path": str(review_path),
"scores": result.get("scores") or {},
"headline": result.get("headline") or "",
+ "deciding_model": result.get("deciding_model") or "",
+ "input_pdf": result.get("input_pdf") or str(self._paper_dir() / "main.pdf"),
+ "reviewers": stored_reviewers,
}
rec.pop("review_error", None)
state["cost_usd"] = round(
float(state.get("cost_usd") or 0.0) + float(result.get("cost") or 0.0), 4
)
+ ar.update_plateau_tracking(state, n)
self._save(state)
self._note(f"round {n} reviewed - {rec['review']['headline']}")
@@ -4362,8 +4619,8 @@ def _close_round(self, state: dict[str, Any], n: int, note: Path) -> None:
state["stage"] = ar.STAGE_AWAIT_FINAL_REVIEW
state["loop_running"] = False
state["stop_reason"] = (
- f"reviewer rated it {int(ar.best_rating(state))}/10, at or above "
- f"the target of {ar.stop_rating(state)}"
+ f"the lowest panel reviewer rated it {int(ar.best_rating(state))}/10, "
+ f"at or above the target of {ar.stop_rating(state)}"
)
self._save(state)
self._note(f"stopping early: {state['stop_reason']}")
@@ -4382,6 +4639,32 @@ def _close_round(self, state: dict[str, Any], n: int, note: Path) -> None:
)
self.stop()
return
+
+ if ar.should_pause_for_plateau(state, n):
+ started = int(state.get("plateau_started_round") or n)
+ state["stage"] = ar.STAGE_AWAIT_FINAL_REVIEW
+ state["loop_running"] = False
+ state["stop_reason"] = (
+ f"the lowest panel rating plateaued at round {started} and did not "
+ f"improve after {ar.PLATEAU_HUMAN_GRACE_ROUNDS} structural repair rounds"
+ )
+ self._save(state)
+ self._note(f"pausing for human input: {state['stop_reason']}")
+ self._emit(
+ "ar-loop-complete",
+ (
+ f"Loom AR task {self.slug} stayed on a score plateau through "
+ f"round {n} and is waiting for your decision."
+ ),
+ {
+ "event": "ar-loop-complete",
+ "round": n,
+ "headline": rec["review"]["headline"],
+ "stop_reason": state["stop_reason"],
+ },
+ )
+ self.stop()
+ return
self._emit(
"ar-round-reviewed",
(
@@ -5004,14 +5287,8 @@ def _ar_action(
return {"ok": False, "error": err}, 400
if str(state.get("review_status")) == "running":
return {"ok": True, "status": "running"}, 202
- meta = read_meta(root, slug)
- model = (
- str(body.get("model", "")).strip()
- or str(state.get("reviewer_model") or "")
- or _ar_headless_model(meta)
- )
ar.update_ar_state(root, slug, review_status="running", review_error="")
- _ar_run_async(_ar_review_job, root, slug, model)
+ _ar_run_async(_ar_review_job, root, slug)
return {"ok": True, "status": "running"}, 202
if action == "submission":
@@ -5566,16 +5843,14 @@ def do_GET(self) -> None: # noqa: N802
self._send(st, b, h)
return
n = int(m_ar_review.group(2))
- path_n = ar.review_note_path(root, slug, n)
- if not path_n.is_file():
+ payload = _ar_review_payload(root, slug, n)
+ if payload is None:
st, b, h = _json_bytes(
{"ok": False, "error": f"no review for round {n}"}, 404
)
self._send(st, b, h)
return
- st, b, h = _json_bytes(
- {"ok": True, "round": n, "review": _ar_read_text(path_n)}
- )
+ st, b, h = _json_bytes(payload)
self._send(st, b, h)
return
diff --git a/loom/web_static/app.css b/loom/web_static/app.css
index c0c67f37..435886a5 100644
--- a/loom/web_static/app.css
+++ b/loom/web_static/app.css
@@ -3010,6 +3010,95 @@ textarea.editable__input {
background: var(--bg-elev-1);
color: var(--text-dim);
}
+.ar-reviewer-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
+ gap: 7px;
+ margin-top: 8px;
+}
+.ar-reviewer-card {
+ position: relative;
+ min-width: 0;
+ padding: 8px 9px;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ background: var(--bg-elev-1);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 2px 8px;
+}
+.ar-reviewer-card.is-deciding {
+ border-color: var(--bad);
+ box-shadow: inset 3px 0 0 var(--bad);
+}
+.ar-reviewer-card__model {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--font-mono, monospace);
+ font-size: 10.5px;
+ font-weight: 700;
+ color: var(--text);
+}
+.ar-reviewer-card__score {
+ font-size: 12px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ color: var(--text);
+}
+.ar-reviewer-card__verdict {
+ grid-column: 1 / -1;
+ font-size: 10.5px;
+ color: var(--text-dim);
+}
+.ar-reviewer-card__badge {
+ position: absolute;
+ right: 8px;
+ bottom: 7px;
+ font-size: 9px;
+ font-weight: 800;
+ color: var(--bad);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.ar-panel-verdict {
+ margin: 0 0 14px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg-elev-2);
+ color: var(--text-dim);
+ font-size: 12px;
+}
+.ar-reviewer-report {
+ margin: 0 0 18px;
+ padding: 14px 16px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--bg-elev-1);
+}
+.ar-reviewer-report.is-deciding {
+ border-color: var(--bad);
+ box-shadow: inset 4px 0 0 var(--bad);
+}
+.ar-reviewer-report__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--border);
+}
+.ar-reviewer-report__head h3 { margin: 0; font-size: 15px; }
+.ar-reviewer-report__eyebrow {
+ margin: 0 0 3px;
+ color: var(--text-faint);
+ font-size: 9px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+.ar-reviewer-report__body { margin-top: 10px; }
.ar-round__summary {
margin: 8px 0 0;
padding: 8px 10px;
diff --git a/loom/web_static/app.js b/loom/web_static/app.js
index 356e050b..abcc4aac 100644
--- a/loom/web_static/app.js
+++ b/loom/web_static/app.js
@@ -4924,6 +4924,52 @@ function renderArGate(state) {
$('#btn-ar-approve').textContent = atDraft ? 'Approve draft' : 'Approve and deliver';
}
+function arReviewerSummaryCards(review, cssPrefix = 'ar') {
+ const reviewers = review && Array.isArray(review.reviewers) ? review.reviewers : [];
+ if (!reviewers.length) return '';
+ const deciding = String((review && review.deciding_model) || '');
+ return `
+ `;
+ }).join('');
+}
+
// The server decides what this paper can accept; the page only reflects it.
// A disabled button carries the reason, so "why can't I press this" is
// answered by the button itself rather than by pressing it and reading a toast.
@@ -953,6 +999,7 @@ function renderPaper(d, state) {
const scores = (review && review.scores) || {};
const chips = Object.entries(scores)
.map(([k, v]) => `${esc(k)} ${esc(v)}`).join(' ');
+ const reviewerCards = reviewerSummaryCards(review);
return `