作者基于WordPress开源插件实践,用AGENTS.md固化构建命令、目录结构和编码约定。其目标是减少Agent重复探索、虚构命令及越界修改。
我是 WPFAevent 的活跃贡献者。这是一个由 FOSSASIA 维护的 WordPress 插件。与 AI coding agent 一起开发了几周后,协作中的摩擦已经明显到让我亲自在仓库里创建了一个 issue:这个项目需要一份 AGENTS.md。差不多同一时期,我也一直在阅读有关 AGENTS.md 的系列文章——文件里应该写什么、为什么它会逐渐过时,以及如何让它始终反映真实情况。这篇文章讲的就是:如何针对一个真实且仍在活跃开发的代码库,真正写出这样一份文件,以及最终合并的版本里都包含了什么。
这不是一次理论练习。在这个仓库里使用不同 AI coding agent 几周后,实际情况已经充分说明了它的必要性:
GitHub Copilot 消耗 AI credits 的速度非常快。因为它每次处理任务时,都必须重新弄清楚同一批项目细节,例如构建命令、编码约定,以及文件位于什么位置。
其他 Agent 经常产生幻觉。它们会建议并不存在的测试命令、编造 capability 名称,还会假设错误的目录结构。
Agent 会重构那些根本没人要求它们修改的代码。你让它“修复这个 bug”,返回的结果却可能顺手格式化或重构了一批无关文件。
版本号会在没有要求的情况下被提升。不止一次,Agent 自行认定一个小修复也是顺便提升版本号的好时机。
架构相关文件被修改得过于频繁。composer.json、composer.lock——这类配置文件,除非得到明确指示,否则 Agent 根本不该碰——却在“只是修复一个 bug”或引入新功能的过程中被改动了。
有些 PR 变得几乎无法审查。一些由 Agent 生成的 PR 一次改动约 40 个文件,新增接近 7,000 行代码。人工审查花费的时间远远超过了这项改动本身应有的成本,就连 GitHub Copilot 自己的 PR review 面对这么大的 diff 也会失效——它无法针对这种规模的改动提供有用且聚焦的反馈。
所有这些现象都指向同一个根本问题:Agent 没有关于该项目的可信信息源,因此只能靠猜;而一个靠猜测工作的 Agent,在成本和破坏性上几乎同样惊人。
AGENTS.md 是放在仓库根目录下的一份 Markdown 文件,用于告诉 AI coding agent 应该如何在这个特定代码库里工作——真实的构建与测试命令、各类内容所在的位置、哪些约定绝不能违背,以及哪些改动必须先让人类参与确认。它已经逐渐成为一种开放且与工具无关的标准:大多数主流 coding agent,包括 Copilot、Claude Code、Cursor、Codex 等,默认都会查找这份文件。
最重要的区别是:README.md 是写给人看的,AGENTS.md 是写给 Agent 看的。README 解释项目是什么、为什么存在——项目介绍、功能特性,以及面向最终用户的安装步骤。AGENTS.md 回答的是一个完全不同的问题:我该如何在这个仓库里工作,同时又不把它弄坏?真正可用的测试命令是什么?哪些目录最重要?哪些东西未经询问绝对不能修改?
它之所以必要,原因非常实际:如果没有它,Agent 每一次都必须从头重建全部上下文,通过阅读文件和猜测约定来理解项目。这不仅速度慢、浪费 tokens/credits,更糟糕的是,一个看似合理的猜测,恰恰会让 Agent 编造出不存在的测试命令或 capability 名称,并悄无声息地破坏某些东西。一份简短且准确的 AGENTS.md,可以把“弄清楚这个项目”变成“阅读一个文件”,也可以把“猜一下然后祈祷没问题”变成“遵守已经明确写下的规则”。
WPFAevent 将基于 Eventyay 的活动集成到 WordPress 中——包括落地页、演讲者、日程和行为准则等内容,所有这些都可以通过页面模板、Gutenberg blocks 或 shortcodes 渲染。项目要求 PHP 7.4+、WordPress 5.8+,使用基于 Composer 的 PHP 工具链,包括 PHPCS、PHPStan 和 PHPUnit;JavaScript 工具链则包括 ESLint 和 Prettier,并在同一时期通过另一个独立 PR 引入。
这是一个真实、活跃且中等规模的代码库——包含 includes/、admin/、public/ 目录和一套测试,并通过 CI 强制执行 WordPress 编码标准。对于这种仓库,AI coding agent 的第一步至关重要:它是要猜测测试命令,还是一眼就能知道正确答案?
即使作为一名已经熟悉项目结构的活跃贡献者,我也不想凭记忆编写 AGENTS.md——人的记忆会发生偏差,而 Agent 会直接相信写下来的内容,不会像人类一样带着怀疑去审视它。因此,我重新回到第一手资料:克隆一份全新的代码库,阅读实际的配置文件,而不是凭回忆描述项目:
composer.json:查看真实存在的 scripts,包括 phpcs、phpcbf、phpstan、test 和 setup-tests。
phpcs.xml:查看实际使用的编码标准规则集,以及其中已经声明的自定义 capability 名称。
phpstan.neon.dist:查看静态分析配置和目标 PHP 版本。
phpunit.xml.dist:查看真实的测试套件名称(Unit、Integration),以及它们所在的位置。
tests/README.md:查看本地测试环境的实际配置方式。
真实的目录树,而不是文字说明中描述的目录树。
最后一点比我原先预想的更重要。README 中的目录结构部分与仓库里的实际情况并不一致——文件名不同,而且那只是一份项目早期阶段遗留下来的过时结构草图。如果我直接把它复制进 AGENTS.md,就等于在 Agent 无条件信任的那份文件中写入了错误信息。还有一点值得特别说明:仓库当时已经有一个并行 PR,准备引入 package.json 以及 ESLint/Prettier 工具。因此,我把 AGENTS.md 中的 npm 命令写成只有该文件确实存在时才执行,而没有在 PR 合并前就把它当成既定事实。
掌握了真实的命令、目录树和配置文件之后,下面就是最终写进文件的内容,以及每一部分值得保留的原因:
Orientation——文件开头只用一行说明:项目是什么、使用什么语言和框架,仅此而已。它会在 Agent 阅读第一行代码之前为其建立正确的先验认知,避免 Agent 打开一个 PHP 文件时还在猜这是不是 Laravel 应用。
Setup & tests——提供可以直接复制粘贴的命令,并把测试放在构建之前。测试是 Agent 唯一能够用现实结果检查自身工作的方法,而不是依赖它对“完成”状态的过时认知。考虑到虚构测试命令正是我要解决的问题之一,这一节直接针对了问题本身。
Lint & static analysis——列出真实的 PHPCS、PHPStan,以及有条件执行的 ESLint/Prettier 命令。这些内容与测试分开列出,因为它们属于不同类型的检查,也有不同的失败模式。
Where things live——简要说明 includes/、admin/、public/、tests/ 分别存放什么,而不是完整倾倒整个目录树。Agent 自己可以运行 ls;但它无法仅凭 ls 知道 eventyay-importer/ 中已经存在 repository 和 API-client 类,应该扩展它们,而不是重新实现一套。
Conventions——规则必须具体且可验证,不能含糊。例如写成“遵循 WordPress Coding Standards,通过 composer phpcs 强制执行”,而不是“编写整洁的代码”。只要某条规则已经由 linter 或配置文件负责,AGENTS.md 就指向该配置,而不再重复描述,避免两边悄悄产生偏差。
Existing patterns——编写新代码之前,先搜索代码库中是否已经存在类似功能;复用 helper 类;扩展现有 hooks,而不是在旁边另建一套平行系统。设置这项规则,直接源于此前的重构与重复造轮子问题——WPFAevent 已经具有实际的内部结构,包括 repositories、importer 层和 cache 类。如果 Agent 不知道它们存在,就很可能在隔着两个目录的位置兴高采烈地再实现一套。
Guardrails,分为三个层级——哪些操作始终安全、哪些操作必须先询问,以及哪些操作绝对禁止。版本号和架构文件问题就在这里得到直接处理:未经询问,不得修改 composer.json、package.json、phpcs.xml、phpstan.neon.dist、phpunit.xml.dist;除非收到明确指示,否则绝不能提升插件或 package 版本号。单纯列一张“禁止事项”清单不如分层规则有效,因为禁止清单并没有告诉 Agent,哪些事情可以不经确认直接去做。
Definition of Done——必须能够通过机械方式检查,让 Agent 知道自己什么时候才算真正完成,而不是靠猜:PHPCS 以状态码 0 退出、PHPStan 以状态码 0 退出、测试通过,而且改动已提交到一个 branch 上。
When stuck——规定默认的升级处理路径:提出问题、给出简短计划,或者创建 draft PR,而不是一次把大量推测性的改动推到多个目录中。
Security & secrets——说明真实凭据存放在哪里,即 WordPress 管理设置中,而不是仓库里;并明确规定“绝不能硬编码这些内容”。
Commit & PR——规定 branch 纪律,把 PR 大小限制在大约 300 行,将无关改动拆分为不同 PR,并使用 Conventional Commit messages。这项规定直接针对那些涉及约 40 个文件、增加约 7,000 行代码的 PR——把大小限制明确写进文件,决定了一个改动究竟能否被人类或 Copilot review 真正评估,还是会因为没人有时间认真阅读而被直接盖章通过。
这些章节无一是为了显得完整而添加;每一节都对应了文件出现之前实际发生过的问题。
# AGENTS.md
WordPress plugin (PHP 7.4+, WordPress 5.8+) integrating Eventyay events into WordPress via page templates, Gutenberg blocks, and shortcodes.
JavaScript tooling is optional. Run npm commands only when `package.json` is present or JavaScript files are being modified.
## Setup
- composer install
- npm install # if JavaScript tooling is needed
## Run the tests
- composer test # full PHPUnit suite
- composer test -- --testsuite Unit # unit tests only
- composer test -- --testsuite Integration # integration tests only (needs local WP test env)
- composer setup-tests # bootstrap the local WordPress test DB (bin/install-wp-tests.sh
## Lint & static analysis
- composer phpcs # WordPress Coding Standards (phpcs.xml)
- composer phpcbf # auto-fix PHPCS violations
- composer phpstan # static analysis (phpstan.neon.dist)
- npm run lint # ESLint (only if package.json is present)
- npm run format # Prettier (only if package.json is present)
- npm run check # lint + phpcs + phpstan (only if package.json is present)
## Where things live
- wpfaevent.php — plugin bootstrap (entry point, defines WPFAEVENT_VERSION/PATH/URL)
- includes/ — core logic: class-wpfaevent.php (main class), class-wpfaevent-loader.php (hooks), class-wpfaevent-templates.php (page templates), cpt/, taxonomies/, meta/, helpers/, cache/, cli/, eventyay-importer/ (API client, repositories, JSON:API parsing)
- admin/ — admin settings pages, dashboard, Eventyay sync/importer UI, partner dashboard
- public/ — public-facing rendering: class-wpfaevent-public.php, partials/, templates/, css/, js/
- tests/ — PHPUnit; tests/unit/ (*Test.php) plus tests/*.php in tests/ (e.g., calendar-test.php); phpunit.xml.dist references tests/integration/ for the Integration suite (currently not present in the repo).
- languages/ — i18n .pot file (Text Domain: wpfaevent)
- bin/install-wp-tests.sh — bootstraps the local WP test database
## Conventions
- Follow WordPress Coding Standards — enforced by `composer phpcs` (phpcs.xml); don't hand-format, run phpcbf.
- Keep PHPStan clean at the configured level (phpstan.neon.dist).
- All user-facing strings wrapped in `__()` / `_e()` with Text Domain `wpfaevent`.
- Core logic in includes/, presentation in public/partials/ and public/templates/ — don't mix business logic into templates.
- Custom capabilities (delete_events, delete_speakers, edit_events, edit_speakers, publish_events, publish_speakers) are listed in phpcs.xml for the WordPress.WP.Capabilities sniff — use them, don't invent new ad hoc capability strings.
- No committed real speaker/event images or large demo data — use placeholders only.
- If JS tooling (package.json) is present in the working tree, JS is linted via `@wordpress/eslint-plugin` and formatted via `@wordpress/prettier-config` — obey those configs, don't restate style rules.
- Keep styles in the appropriate CSS files; avoid inline CSS unless explicitly required.
## Existing patterns
Before implementing anything:
- Search for similar functionality before adding new code.
- Follow existing naming conventions.
- Reuse helper classes where possible.
- Extend existing hooks instead of introducing parallel systems.
## Guardrails
- Always: Read relevant files before editing. Follow the project conventions. Run the required validation commands before considering work complete.
- Ask first: running `composer test -- --testsuite Integration` against anything but a local/throwaway DB, adding new dependencies, changing phpcs.xml / phpstan.neon.dist / phpunit.xml.dist, touching bin/install-wp-tests.sh.
- Never:
- commit vendor/, node_modules/, coverage/, or .phpunit.result.cache.
- commit real speaker/event data or large binaries.
- commit directly to main - branch and open a PR.
- commit secrets or API endpoint credentials.
- Update plugin or package version numbers unless
实际指令大约只有 50 行——完全处于该系列文章所提倡的“足够简短,因此每一行都值得信任”的范围内;而且其中的每一条命令,我都在文件提交之前亲自运行过。
有几件事给我留下的印象尤其深刻:
阅读仓库,而不是 README。两者产生偏差的速度比你想象得更快;而且当 README 出错时,阅读它的人至少还会带着怀疑。AGENTS.md 没有这种待遇——Agent 会直接运行它被告知要运行的内容。
指向配置,而不是复制配置。“遵循由 composer phpcs 强制执行的 WordPress Coding Standards”这句话,在 phpcs.xml 规则发生变化后依然成立。“使用两个空格缩进,优先使用数组而不是 array()”则不会——linter 配置一旦改变,这一行就会悄悄变成错误信息,而且在 Agent 相信它之前可能都不会有人注意到。
每一条 guardrail 都应该能追溯到一个真正发生过的问题。我没有凭空写下“不要提升版本号”或“修改 phpcs.xml/phpstan.neon.dist 之前先询问”这些规则——它们之所以存在,是因为在 AGENTS.md 出现之前,Agent 已经在这个仓库里做过完全相同的事情。如果一份 guardrail 清单并非源于真实事故,它往往要么遗漏真正的雷区,要么塞满从未有人需要过的规则。
关于一个并不存在的目录,过时的说明比完全不写更糟。phpunit.xml.dist 为 Integration 测试套件引用了 tests/integration/,但这个目录实际上还不存在于仓库中——因此文件明确说明了这一点,而不是暗示一个并不存在的目录已经存在。一份看起来权威但内容悄悄出错的文件,会和正确的文件一样被 Agent 毫不犹豫地执行。
这份文件最终通过 PR #192 合并。它很简短,其中的每条命令都可以运行。下一位贡献者——无论是人还是 Agent——只需三十秒,就能了解我花了整整一个下午阅读真实配置文件才掌握的全部信息。
已合并的 PR:fossasia/WPFAevent#192
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。