From 5ed023d64ecd34cd29edf1ecec77a02417e68d27 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 22 Oct 2026 10:14:51 +0800 Subject: [PATCH] emrg: the READMEs read their facts from the code, and a guard keeps them true --- Agent.md | 2 +- README.cn.md | 12 +- README.md | 12 +- emrg/server/daemon.py | 61 ++-- tests/test_readme_matches_code.py | 457 ++++++++++++++++++++++++++++++ 5 files changed, 517 insertions(+), 27 deletions(-) create mode 100644 tests/test_readme_matches_code.py diff --git a/Agent.md b/Agent.md index b0fff4d4..ce4c8e41 100644 --- a/Agent.md +++ b/Agent.md @@ -34,7 +34,7 @@ The execution model, in this repo's own terms (`emrg/server/daemon.py`): - **Tool loop** — the whole process triggered by one user message: LLM requests and tool calls repeat until a round produces no new tool calls (`_run_tool_loop`). - **Round** — one iteration inside a tool loop: one LLM request + zero or more tool calls + their execution (`round_num`, bounded by `max_tool_rounds`). -- **Evolution cycle** — one full self-evolution run (Prepare → Review → Discover → Improve → Submit → Record). Never call it a "round". +- **Evolution cycle** — one full self-evolution run (Preparation → Look → Think → Act → Record). Never call it a "round". ## Capabilities diff --git a/README.cn.md b/README.cn.md index a2692467..1661b6db 100644 --- a/README.cn.md +++ b/README.cn.md @@ -47,7 +47,7 @@ EMRG 是一个关于*自主进化*的实验:一个能帮你写代码的 AI 智 |---|---| | **Electron GUI(主入口)** | 安装即用:首次启动引导配置 API Key;全部 `/` 指令可用;结果面板、Ask/Auto 模式、自进化可见化。GUI 配好即 TUI 可用 | | **全功能 TUI + 守护进程** | 流式 Markdown、`/` 自动补全、会话管理、快捷键——跑在常驻的 `emrgd` 守护进程上,随时重连 | -| **定时任务** | 内置 + 自定义任务类型(提示词模板存 `~/.emrg/task-templates/`)、GUI 设置页增删改 + 热加载、`/trigger` 随时手动触发 | +| **定时任务** | 内置任务类型(`evolution`、`paper`、`open-source`、`promote`、`journal`、`competition`、`ios-app`、`feishu`)加自定义类型(提示词模板存 `~/.emrg/task-templates/`)、GUI 设置页增删改 + 热加载、`/trigger` 随时手动触发 | | **100% 开源** | MIT 协议——没有围墙,没有厂商锁定。默认英文,提供中文版 | --- @@ -64,8 +64,8 @@ EMRG 不只是一个工具——它是一个**会听吐槽、会自我改进** - 跨项目学习 | v -演化循环(每 30 分钟) -(准备 -> 回顾 -> 发现 -> 改进 -> 提交 -> 记录) +演化循环(默认每 10 分钟) +(Preparation -> Look -> Think -> Act -> Record) | v 1. pytest + import 检查 @@ -148,12 +148,14 @@ TUI 内:`Esc` 中断流式输出,`Ctrl+C` / `exit` 退出(配置与 GUI | `/memory` | 浏览项目和会话记忆 | | `/rename [标题]` | 给当前会话起个好记的名字 | | `/model [name]` | 切换 LLM 模型——不带参数进入交互式选择器 | +| `/sandbox [mode]` | 设置本会话的沙箱档位——不带参数弹出选择器;`add`/`remove`/`list` 管理额外可写根 | | `/rant <反馈> [@]` | 吐槽、建议、夸奖——演化系统会听 | | `/help` | 查看所有键盘快捷键和命令帮助 | | `/image` | 从剪贴板插入图片到输入框 | | `/delete [id]` | 删除会话——不带参数进入交互式选择器 | | `/rewind` | 回退对话到历史某个节点 | | `/trigger` | 触发演化任务——交互式选择器(任务管理在 GUI 设置 → 定时任务) | +| `/task-session` | 打开某个定时任务自己的会话——交互式选择器 | | `/skills` | 列出已加载技能;`/skills available\|install\|update` | | `/version` | 显示 EMRG 版本和实例信息 | | `Esc` | 中断正在运行的响应 | @@ -166,13 +168,13 @@ TUI 内:`Esc` 中断流式输出,`Ctrl+C` / `exit` 退出(配置与 GUI | | Claude Code | Codex | DeepSeek Harness | **EMRG** | **Grok Bot** | |---|---|---|---|---|---| | AI 驱动编程 | ✅ | ✅ | ✅ | ✅ | ✅ | -| 工具调用 (bash, read, write, edit, glob, grep) | ✅ | ✅ | ✅ | ✅ | ✅ | +| 工具调用 (`bash`/`pwsh`、`read`、`write`、`edit`、`glob`、`grep`、`submit_rant`、`subagent`) | ✅ | ✅ | ✅ | ✅ | ✅ | | 会话记忆与上下文 | ✅ | ✅ | ✅ | ✅ | ✅ | | `/` 命令自动补全 | ✅ | ✅ | ✅ | ✅ | ~ *@ mention 建议 + 命令面板* | | ESC 中断 | ✅ | ✅ | — | ✅ | ✅ | | 插件化架构 | ❌ | ❌ | ✅ *一切皆插件* | ❌ | ✅ | | 沙箱执行 | ❌ | ❌ | ✅ | ✅ *read-only / workspace-write / danger-full-access* | ~ *protectedBoxPaths + /workspace* | -| 子代理 | ❌ | ❌ | ✅ | ❌ | ✅ *assistants/automations 生态* | +| 子代理 | ❌ | ❌ | ✅ | ✅ *子会话委派* | ✅ *assistants/automations 生态* | | Web UI | ❌ | ❌ | ✅ *浏览器* | ✅ *Electron* | ✅ *Electron* | | **递归自我改进(RSI)** | ❌ | ❌ | ❌ | ✅ *全自动——代码、提示词、工具链* | ❌ | | **个人智能体** *你的机器、你的 key、你的数据* | ~ *本地 CLI,模型为托管服务,走你的账号* | ~ *本地 CLI,模型为托管服务* | ~ *本地 harness,模型为托管服务* | ✅ *本地智能体,用你的 key,状态在你磁盘上* | ~ *本地应用,托管服务* | diff --git a/README.md b/README.md index 5bc7620c..2e16d1c5 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ EMRG is an experiment in *autonomous self-improvement*: an AI agent that helps y |---|---| | **Electron GUI (main entry)** | Install & go: first-run wizard, all slash commands, results panel, Ask/Auto modes, visible self-evolution. GUI configured = TUI ready | | **Full-featured TUI + daemon** | Streaming markdown, `/` autocomplete, sessions, shortcuts — on a persistent `emrgd` daemon you can reconnect to anytime | -| **Scheduled tasks** | Built-in + custom task types with prompt templates (`~/.emrg/task-templates/`), CRUD + hot reload from the GUI settings, `/trigger` any task on demand | +| **Scheduled tasks** | Built-in task types (`evolution`, `paper`, `open-source`, `promote`, `journal`, `competition`, `ios-app`, `feishu`) plus custom ones with prompt templates in `~/.emrg/task-templates/`, CRUD + hot reload from the GUI settings, `/trigger` any task on demand | | **100% open source** | MIT — no walled garden, no vendor lock-in. English default, Chinese version available | --- @@ -64,8 +64,8 @@ Inputs: - Cross-project learning | v -Evolution Cycle (every 30 min) -(Prepare -> Review -> Discover -> Improve -> Commit -> Record) +Evolution Cycle (every 10 min by default) +(Preparation -> Look -> Think -> Act -> Record) | v 1. pytest + import check @@ -150,12 +150,14 @@ In the TUI: `Esc` interrupts streaming, `Ctrl+C` / `exit` quits (config is share | `/memory` | Browse project & session memories | | `/rename [title]` | Give your session a memorable name | | `/model [name]` | Switch LLM model — no args for interactive picker | +| `/sandbox [mode]` | Set this session's sandbox tier — no args for a picker; `add`/`remove`/`list` manage extra writable roots | | `/rant [@]` | Complain, suggest, praise — evolution listens | | `/help` | Show keyboard shortcuts and command help | | `/image` | Insert clipboard image into the input field | | `/delete [id]` | Delete a session — no args for interactive picker | | `/rewind` | Rewind conversation to a history point | | `/trigger` | Trigger an evolution task — interactive picker (manage tasks in GUI Settings → Scheduled tasks) | +| `/task-session` | Open a scheduled task's own session — interactive picker | | `/skills` | List loaded skills; `/skills available\|install\|update` | | `/version` | Show EMRG version and instance info | | `Esc` | Interrupt a running response mid-stream | @@ -168,13 +170,13 @@ In the TUI: `Esc` interrupts streaming, `Ctrl+C` / `exit` quits (config is share | | Claude Code | Codex | DeepSeek Harness | **EMRG** | **Grok Bot** | |---|---|---|---|---|---| | AI-powered coding | ✅ | ✅ | ✅ | ✅ | ✅ | -| Tool-calling (bash, read, write, edit, glob, grep) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Tool-calling (`bash`/`pwsh`, `read`, `write`, `edit`, `glob`, `grep`, `submit_rant`, `subagent`) | ✅ | ✅ | ✅ | ✅ | ✅ | | Session memory & context | ✅ | ✅ | ✅ | ✅ | ✅ | | `/` command autocomplete | ✅ | ✅ | ✅ | ✅ | ~ *@ mention + command palette* | | ESC interrupt | ✅ | ✅ | — | ✅ | ✅ | | Plugin architecture | ❌ | ❌ | ✅ *everything is a plugin* | ❌ | ✅ | | Sandboxed execution | ❌ | ❌ | ✅ | ✅ *read-only / workspace-write / danger-full-access* | ~ *protectedBoxPaths + /workspace* | -| Subagents | ❌ | ❌ | ✅ | ❌ | ✅ *assistants/automations* | +| Subagents | ❌ | ❌ | ✅ | ✅ *delegated child sessions* | ✅ *assistants/automations* | | Web UI | ❌ | ❌ | ✅ *browser* | ✅ *Electron* | ✅ *Electron* | | **Recursive self-improvement (RSI)** | ❌ | ❌ | ❌ | ✅ *autonomous — code, prompts, toolchain* | ❌ | | **Personal agent** *your machine, your key, your data* | ~ *local CLI, hosted model, your account* | ~ *local CLI, hosted model* | ~ *local harness, hosted model* | ✅ *local agent, your key, its state on your disk* | ~ *local app, hosted service* | diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 8e4f8867..3a48b3f7 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -639,6 +639,50 @@ def build_shell_tool( return BashToolV2() +def build_tool_registry( + sandbox_config: Optional[SandboxConfig] = None, + run_subagent_cb=None, + platform_name: str | None = None, +) -> ToolRegistry: + """The daemon's tool set, built in one place so that its size is a reading. + + The registry is the one fact about this agent that a document cannot restate + by hand and stay true: `README.md` names the tools, and a name added here is a + name that document stops being right about the moment it is added. Something + therefore has to compare the two, and a comparison that lives in a test needs a + producer it can call **without a daemon** — a copy of this block in the test + would be a second list to keep in step, which is the defect + `emrg/tools/shell_dialects.py` exists to name. So the block is here, the daemon + calls it, and `tests/test_readme_matches_code.py` reads the same function. + + :param sandbox_config: the ``[sandbox]`` section, passed through to + :func:`build_shell_tool`; a missing one means the defaults. + :param run_subagent_cb: the caller's own runner. The tool has no path to the + session machinery of its own, so everything that owns state stays with the + daemon that supplies it. + :param platform_name: the platform to decide the shell dialect for; defaults to + the host's, which is what the daemon wants and what a test overrides. + :returns: the populated registry. + """ + tools = ToolRegistry() + # Which shell this platform mounts — and, on POSIX, which of the two + # executors answers to the name `bash`. The gate is composition data + # rather than a runtime branch (design §14.2 layer 6): Windows has no + # `bash` tool row at all, so "bash is not found" is not representable + # there. See `build_shell_tool` for the three cases. + tools.register(build_shell_tool(sandbox_config, platform_name)) + tools.register(ReadTool()) + tools.register(WriteTool()) + tools.register(EditTool()) + tools.register(GlobTool()) + tools.register(GrepTool()) + tools.register(SubmitRantTool()) + # The runner is the daemon's own method, so the tool has no path to the + # session machinery of its own: everything that owns state stays there. + tools.register(SubagentTool(run_subagent_cb)) + return tools + + class EmrgServer: """EMRG daemon — listens on Unix socket, processes tasks with tool calling.""" @@ -798,22 +842,7 @@ def __init__(self, llm_config: LlmConfig) -> None: self._pending_web_auth_proc: Optional[asyncio.subprocess.Process] = None # Build tool registry - self.tools = ToolRegistry() - # Which shell this platform mounts — and, on POSIX, which of the two - # executors answers to the name `bash`. The gate is composition data - # rather than a runtime branch (design §14.2 layer 6): Windows has no - # `bash` tool row at all, so "bash is not found" is not representable - # there. See `build_shell_tool` for the three cases. - self.tools.register(build_shell_tool(self._sandbox_config)) - self.tools.register(ReadTool()) - self.tools.register(WriteTool()) - self.tools.register(EditTool()) - self.tools.register(GlobTool()) - self.tools.register(GrepTool()) - self.tools.register(SubmitRantTool()) - # The runner is the daemon's own method, so the tool has no path to the - # session machinery of its own: everything that owns state stays here. - self.tools.register(SubagentTool(self._run_subagent_tool)) + self.tools = build_tool_registry(self._sandbox_config, self._run_subagent_tool) logger.info("tools registered: %s", self.tools.names) # Skills are **not** held here. A snapshot taken at startup cannot see a skill diff --git a/tests/test_readme_matches_code.py b/tests/test_readme_matches_code.py new file mode 100644 index 00000000..0cd6531d --- /dev/null +++ b/tests/test_readme_matches_code.py @@ -0,0 +1,457 @@ +"""Every fact a README states about this tree is read from the tree. + +Why this file exists +-------------------- +`README.md` and `README.cn.md` described an EMRG older than the one on disk — six facts +had drifted, and all six drifted the same way: **a value a reader can obtain from the code +was written into prose by hand**, so nothing in the repository compared the two. The +command table held 15 rows against `_COMMAND_HELP`'s 17; the comparison table put ❌ in +EMRG's own `Subagents` cell while `SubagentTool` was registered at start-up; the evolution +cadence read 30 minutes where a fresh install runs the seeded 600-second record; the tool +row named six of the eight registered names; eight built-in task types went undisclosed; +and the phase list named six steps of which three appear nowhere in +`emrg/server/evolution_prompt.md`. A reader following those documents was told, in one +case, the exact opposite of what the binary does. + +**Each fact has one producer, and the guard reads that producer rather than a copy.** The +tool roster is `build_tool_registry` in `emrg/server/daemon.py` — extracted for this file, +because a copy of the registration block here would be a second list to keep in step, which +is the defect `emrg/tools/shell_dialects.py` exists to name. The commands come from +`_COMMAND_HELP`, the same dictionary the TUI's `/help` renders. The cadence comes from +`DEFAULT_TASK_RECORD`, the record the daemon seeds; `DEFAULT_INTERVAL` in the same module is +a *different* subject (the fallback for a hand-written record that omits the field) and is +deliberately not the number asserted here. + +**Every check reads both directions.** A row the table carries and the code does not offer +is as much a drift as the opposite, and it is the direction a one-way check never reports. + +**Every check has a control arm.** A reading that prints the healthy answer whatever it is +given has measured nothing, so each test below is paired with a mutation of the real +document — a deleted row, a renamed tool, a changed number, a flipped ✅, a renamed phase — +that must turn exactly that reading red. The arms run over both languages, because the pair +is what drifts: an edit to one README alone is the shape this whole file exists to catch. + +**A subject that cannot be found is reported, never passed.** A README rewritten so that an +anchor is gone makes the reading answer "nothing measured" rather than "no drift"; the +checkers return that as a problem, which is why a green run means the anchor was found *and* +agreed. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from emrg.client.widgets import _COMMAND_HELP +from emrg.server.daemon import build_tool_registry +from emrg.server.scheduler import DEFAULT_TASK_RECORD, TASK_TEMPLATES + +REPO_ROOT = Path(__file__).resolve().parent.parent +READMES = (REPO_ROOT / "README.md", REPO_ROOT / "README.cn.md") +EVOLUTION_TEMPLATE = REPO_ROOT / "emrg" / "server" / "evolution_prompt.md" + +#: A cell boundary — a pipe that is not escaped. GFM splits a row here, and this +#: repository's `/skills` row carries escaped pipes inside a code span. +_CELL_BOUNDARY = re.compile(r"(? list[str] | None: + """The cells of a markdown table row, or ``None`` for any other line.""" + stripped = line.strip() + if not stripped.startswith("|") or not stripped.endswith("|"): + return None + return [cell.strip() for cell in _CELL_BOUNDARY.split(stripped[1:-1])] + + +def _plain(cell: str) -> str: + """A cell with its emphasis markers removed.""" + return cell.replace("*", "").replace("`", "").strip() + + +def _parenthesised(text: str, anchors: tuple[str, ...]) -> str | None: + """The contents of the parenthesis that follows one of ``anchors``. + + Both paren styles are accepted, because a Chinese sentence legitimately uses the + full-width pair and the tool row of the same document may use the ASCII one. What + the reading needs is the group's contents, not the punctuation that framed it. + """ + for anchor in anchors: + match = re.search(re.escape(anchor) + r"\s*[\((]([^))]*)[\))]", text) + if match: + return match.group(1) + return None + + +def _names_in(text: str, anchors: tuple[str, ...]) -> set[str] | None: + """The names a parenthesised list after ``anchors`` holds, split on either separator. + + A token may carry two names joined by a slash — `bash`/`pwsh` is the platform + roster's spelling — so each token is split on `/` as well. + """ + group = _parenthesised(text, anchors) + if group is None: + return None + cleaned = group.replace("`", "").replace("*", "") + names: set[str] = set() + for token in re.split(r"[,、]", cleaned): + for part in token.split("/"): + part = part.strip() + if part: + names.add(part) + return names + + +def _documented_commands(text: str) -> set[str]: + """Every ``/command`` a table row opens with, across the whole document.""" + found: set[str] = set() + for line in text.splitlines(): + cells = _cells(line) + if cells: + match = _COMMAND_ROW.match(cells[0]) + if match: + found.add(match.group(1)) + return found + + +def _stated_interval(text: str) -> int | None: + """The evolution cadence in minutes, as the document states it.""" + for pattern in _CADENCE: + match = pattern.search(text) + if match: + return int(match.group(1)) + return None + + +def _stated_phases(text: str) -> list[str] | None: + """The phase list that follows the cadence sentence, in document order.""" + lines = text.splitlines() + for index, line in enumerate(lines): + if any(pattern.search(line) for pattern in _CADENCE): + for following in lines[index + 1:]: + if not following.strip(): + continue + stripped = following.strip().strip("()()") + return [part.strip() for part in re.split(r"->|→", stripped) if part.strip()] + return None + + +def _subagent_cell(text: str) -> str | None: + """EMRG's own cell on the `Subagents` row of the comparison table. + + The column is located from the header row rather than assumed: a column inserted + before EMRG's would otherwise turn this reading into a reading of its neighbour. + """ + column: int | None = None + for line in text.splitlines(): + if not line.strip(): + column = None + continue + cells = _cells(line) + if cells is None: + column = None + continue + if column is None and any("EMRG" in cell for cell in cells): + column = next(i for i, cell in enumerate(cells) if "EMRG" in cell) + continue + if column is not None and _plain(cells[0]) in ("Subagents", "子代理"): + return cells[column] if column < len(cells) else None + return None + + +# ── the code's own answers ────────────────────────────────────────────────── + + +def tool_roster() -> set[str]: + """Every tool name the daemon can register, on any platform. + + Two platforms are built because the shell dialect is composition, not a branch: + POSIX mounts `bash` and Windows mounts `pwsh`, and a document that named only one + of them would be wrong on the other host. + """ + roster: set[str] = set() + for platform_name in ("linux", "win32"): + roster |= set(build_tool_registry(None, None, platform_name=platform_name).names) + return roster + + +def template_phases() -> list[str]: + """The numbered section headings of the evolution template, in order.""" + text = EVOLUTION_TEMPLATE.read_text(encoding="utf-8") + return [match.group(2) for match in _PHASE_HEADING.finditer(text)] + + +# ── the six readings ──────────────────────────────────────────────────────── + + +def command_drift(text: str, offered: set[str]) -> list[str]: + documented = _documented_commands(text) + if not documented: + return ["no `/command` row was found in a table — the reading measured nothing"] + missing = sorted(offered - documented) + extra = sorted(documented - offered) + return ( + [f"{name} is offered by the app and has no row" for name in missing] + + [f"{name} has a row and the app does not offer it" for name in extra] + ) + + +def tool_drift(text: str, roster: set[str]) -> list[str]: + documented = _names_in(text, _TOOL_ANCHORS) + if documented is None: + return ["the tool-calling row was not found — the reading measured nothing"] + return ( + [f"{name} is registered and the row omits it" for name in sorted(roster - documented)] + + [f"{name} is named in the row and nothing registers it" + for name in sorted(documented - roster)] + ) + + +def interval_drift(text: str, seeded_seconds: int) -> list[str]: + minutes = _stated_interval(text) + if minutes is None: + return ["the evolution cadence sentence was not found — the reading measured nothing"] + if minutes * 60 != seeded_seconds: + return [ + f"the cadence reads {minutes} min; the task record the daemon seeds runs " + f"{seeded_seconds} s" + ] + return [] + + +def task_type_drift(text: str, types: set[str]) -> list[str]: + documented = _names_in(text, _TASK_TYPE_ANCHORS) + if documented is None: + return ["the built-in task-type list was not found — the reading measured nothing"] + return ( + [f"{name} is a built-in task type and is undisclosed" for name in sorted(types - documented)] + + [f"{name} is disclosed as a built-in and no template renders it" + for name in sorted(documented - types)] + ) + + +def phase_drift(text: str, phases: list[str]) -> list[str]: + stated = _stated_phases(text) + if stated is None: + return ["the phase list was not found — the reading measured nothing"] + if stated != phases: + return [ + f"the phase list reads {' -> '.join(stated)}; the template's headings are " + f"{' -> '.join(phases)}" + ] + return [] + + +def subagent_drift(text: str, registered: bool) -> list[str]: + cell = _subagent_cell(text) + if cell is None: + return ["the Subagents row was not found — the reading measured nothing"] + marked = cell.startswith("✅") + if registered and not marked: + return [f"the Subagents cell reads {cell!r} while a subagent tool is registered"] + if marked and not registered: + return ["the Subagents cell reads ✅ and nothing registers a subagent tool"] + return [] + + +# ── the real corpus ───────────────────────────────────────────────────────── + + +def _read(readme: Path) -> str: + return readme.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_every_command_the_app_offers_has_a_row(readme: Path) -> None: + problems = command_drift(_read(readme), set(_COMMAND_HELP)) + assert not problems, f"{readme.name}: " + "; ".join(problems) + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_the_tool_row_names_every_registered_tool(readme: Path) -> None: + problems = tool_drift(_read(readme), tool_roster()) + assert not problems, f"{readme.name}: " + "; ".join(problems) + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_the_stated_cadence_is_the_one_the_daemon_seeds(readme: Path) -> None: + problems = interval_drift(_read(readme), DEFAULT_TASK_RECORD["interval"]) + assert not problems, f"{readme.name}: " + "; ".join(problems) + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_the_stated_task_types_are_the_templates_that_render(readme: Path) -> None: + problems = task_type_drift(_read(readme), set(TASK_TEMPLATES)) + assert not problems, f"{readme.name}: " + "; ".join(problems) + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_the_stated_phases_are_the_templates_headings(readme: Path) -> None: + problems = phase_drift(_read(readme), template_phases()) + assert not problems, f"{readme.name}: " + "; ".join(problems) + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_the_subagent_cell_agrees_with_the_registry(readme: Path) -> None: + registered = "subagent" in tool_roster() + problems = subagent_drift(_read(readme), registered) + assert not problems, f"{readme.name}: " + "; ".join(problems) + + +def test_no_derived_list_was_left_out_of_the_documents() -> None: + """The subjects themselves are not empty, so the comparisons above mean something. + + A check whose expected set is empty passes on any document: both directions are + vacuously true. This is the arm that notices the producers being emptied. + """ + assert len(_COMMAND_HELP) > 5 + assert len(tool_roster()) > 5 + assert len(TASK_TEMPLATES) > 5 + assert len(template_phases()) > 3 + assert DEFAULT_TASK_RECORD["interval"] > 0 + + +# ── the control arms ──────────────────────────────────────────────────────── +# +# Each one mutates the real document and asserts that exactly its own reading turns +# red. Without them a checker that returned `[]` unconditionally would pass every test +# above — the shape this repository names as "a guard nobody runs" is a sibling of it. + + +def _without_row(text: str, cell_prefix: str) -> str: + """Drop the first table row whose first cell starts with ``cell_prefix``. + + Matched on the *cell*, not on the line: the command row reads + ```| `/sandbox [mode]` |```, so a line-prefix match written against the bare command + name finds nothing and the arm would mutate no document at all. + """ + kept: list[str] = [] + removed = 0 + for line in text.splitlines(keepends=True): + cells = _cells(line) + if removed == 0 and cells and cells[0].startswith(cell_prefix): + removed += 1 + continue + kept.append(line) + assert removed == 1, f"the mutation removed {removed} rows" + return "".join(kept) + + +def _renumber_cadence(text: str, minutes: int) -> str: + for pattern in _CADENCE: + match = pattern.search(text) + if match: + return text[:match.start(1)] + str(minutes) + text[match.end(1):] + raise AssertionError("the cadence sentence was not found to mutate") + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_a_deleted_command_row_is_reported(readme: Path) -> None: + text = _read(readme) + assert not command_drift(text, set(_COMMAND_HELP)) + mutated = _without_row(text, "`/sandbox") + problems = command_drift(mutated, set(_COMMAND_HELP)) + assert "/sandbox is offered by the app and has no row" in problems + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_a_row_the_app_does_not_offer_is_reported(readme: Path) -> None: + text = _read(readme) + mutated = text.replace( + "| `/help` |", + "| `/nonexistent` | Nothing offers this |\n| `/help` |", + 1, + ) + assert mutated != text + problems = command_drift(mutated, set(_COMMAND_HELP)) + assert "/nonexistent has a row and the app does not offer it" in problems + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_a_renamed_tool_is_reported_in_both_directions(readme: Path) -> None: + roster = tool_roster() + text = _read(readme) + assert not tool_drift(text, roster) + mutated = text.replace("submit_rant", "submit_rant_renamed", 1) + assert mutated != text + problems = tool_drift(mutated, roster) + assert "submit_rant is registered and the row omits it" in problems + assert "submit_rant_renamed is named in the row and nothing registers it" in problems + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_a_changed_cadence_is_reported(readme: Path) -> None: + seeded = DEFAULT_TASK_RECORD["interval"] + text = _read(readme) + assert not interval_drift(text, seeded) + problems = interval_drift(_renumber_cadence(text, seeded // 60 + 20), seeded) + assert problems and "the cadence reads" in problems[0] + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_a_missing_task_type_is_reported_in_both_directions(readme: Path) -> None: + types = set(TASK_TEMPLATES) + text = _read(readme) + assert not task_type_drift(text, types) + mutated = text.replace("feishu", "feishu_renamed", 1) + assert mutated != text + problems = task_type_drift(mutated, types) + assert "feishu is a built-in task type and is undisclosed" in problems + assert "feishu_renamed is disclosed as a built-in and no template renders it" in problems + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_a_renamed_phase_is_reported(readme: Path) -> None: + phases = template_phases() + text = _read(readme) + assert not phase_drift(text, phases) + second = phases[1] + mutated = text.replace(f"-> {second} ->", "-> Review ->", 1) + assert mutated != text + problems = phase_drift(mutated, phases) + assert problems and "the phase list reads" in problems[0] + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_a_flipped_subagent_cell_is_reported(readme: Path) -> None: + registered = "subagent" in tool_roster() + text = _read(readme) + assert not subagent_drift(text, registered) + cell = _subagent_cell(text) + assert cell is not None + flipped = "❌ *delegated*" if cell.startswith("✅") else "✅ *delegated*" + mutated = text.replace(cell, flipped, 1) + assert mutated != text + problems = subagent_drift(mutated, registered) + assert problems + + +@pytest.mark.parametrize("readme", READMES, ids=lambda path: path.name) +def test_an_anchor_that_went_missing_is_reported_not_passed(readme: Path) -> None: + """A document whose anchor is gone is *not measurable*, and that is not a pass.""" + text = _read(readme) + stripped = re.sub(r"Tool-calling[^\n]*|工具调用[^\n]*", "", text, count=1) + problems = tool_drift(stripped, tool_roster()) + assert problems and "measured nothing" in problems[0]