/** * Unit Tests —— 本体 md 里相对路径引用的「静默降级」防线(28 号改造,裁决 A) * * 挡的是这一类缺陷:`system/agents/{aid}/agent.md` 里写着 `../../workspace/org-planner/prd.md` * 或 `../../../docs/todo/system-design/27-naming-conventions.md`,而 28 号把 CLI 的 cwd 从 * 本体根目录下沉到了 `system/agents/{aid}/{companyId}/{taskId}/`(深度 3)。深度一变, * 每一条 `../` 引用都指到别处 —— **而这条失败路径上没有任何一处会报错**: * * - 输入读不到 → agent 在「没看见 prd / sdd / acceptance / 设计文档」的状态下照样开工, * 产出看起来是正常产出,只是依据是它自己编的(org-coder 就是这么跑偏的); * - 输出写错地方 → 落进 agent 自己的私有目录,`indexNodeOutputs` 扫不到、`graph_files` * 里没有记录,下游节点的「[上游输入]」段是空的 —— 同样不报错。 * * 提示词开头的「运行时坐标」给的是绝对路径,是兜底的那一半;本文件是另一半: * 保证 md 里那些相对引用**本身**算得对。两半都在,改漏一处才有人吭声。 * * ── 基准(裁决 A,cwd 深度恒为 3)────────────────────────────────── * cwd system/agents/{aid}/{companyId}/{taskId}/ * `../` 公司态目录 system/agents/{aid}/{companyId}/ (运行期创建,见下) * `../../` 本体目录 system/agents/{aid}/ * `../../../` 本体注册表 system/agents/ * `../../../../` system/ * `../../../../../` 仓库根 * * 深度是 path.relative 实算出来的,不照抄设计稿 —— 28 §4.1 的 `../../../` 把结尾斜杠 * 数成了一级,prompt-builder 的注释已经记过这笔账。本文件第一条用例就把这个常量钉死。 */ import { describe, it, expect } from 'vitest'; import fs from 'fs'; import path from 'path'; const ROOT = path.resolve(__dirname, '../../..'); const AGENTS_DIR = path.join(ROOT, 'system', 'agents'); /** 探针:真实 companyId / taskId 在运行期才有,这里拿两个不会与任何东西撞名的占位符顶上 */ const PROBE_CID = '_probe_cid'; const PROBE_TASK = '_probe_task'; const probeCwd = (aid) => path.join(AGENTS_DIR, aid, PROBE_CID, PROBE_TASK); /** * 不算「本体」的 md: * todo.md / plan.md / memory.md / _lessons.md —— agent 自己写的运行态清单(graph-engine * 的 AGENT_RUNTIME_STATE_RE 豁免的正是这几个),里面的路径是历史派单原文,不是指令。 * prompt.md —— DevLoop 六个 agent 的模板。它们走 loop-controller.callAgent, * cwd 是 **DevLoop 项目根**(`protocol.connect(null, projectRoot)`),不是 agent 任务态目录, * 里面的 `../../sdd/` 是相对 `units/{unitId}/` 说的。拿本文件这套基准去量它会把对的判成错的。 */ const NOT_BODY_MD = new Set(['todo.md', 'plan.md', 'memory.md', '_lessons.md', 'prompt.md']); /** * 允许「目标暂时不存在」的引用落点 —— 键是解析后的仓库相对路径,值是理由。 * * 只收**写产物的落点**:它们由 agent 首次运行时创建,静态检查看不到不代表算错了。 * 判据不是「反正找不到就放行」,而是每一条都要能说清「谁在什么时候把它建出来」。 * 名单必须保持极短;每加一条就是给「指到空气」开了一道口子。 */ const PENDING_WRITE_TARGETS = { 'system/train/digester': 'innovator-digest 的产出目录,首次 digest 时创建;同层的 system/train/hunter 已有实例,深度按它对齐', 'system/agents/master/plan.md': 'master 自己维护的子任务清单,首次规划时写出(AgentScaffold 的 _writePlan 也走同一处)', 'system/agents/youtube-crawler/workspace/evolve/refactor_report.md': 'evolver 的重构报告,跑 code-refactor 技能时写出;同目录的 benchmark_test.js / evolving_log.md 已在', }; /** * 公司态目录(`../`)里的已知内容。这一层是 **(agentId, companyId) 运行期**才建的 * (company-service 雇佣时写 company-context.md + user.md + 空 memory),静态断言「存在」 * 恒为假。所以这一层只校验**名字**:写了个不属于这一层的名字,同样是算错了深度。 */ const COMPANY_STATE_ENTRIES = new Set(['company-context.md', 'user.md', 'memory', 'conversations']); /** * 列目录,目录中途消失就当空。 * * 本文件扫的是**真实的** `system/agents/`,而同一轮 vitest 里别的用例会在这底下建了又删 * 临时本体(`tmp-iso-agent-`,见 agent-chat-tenant-isolation)。并行跑起来必然撞上 * 「readdir 列到了名字、进去时已经被删」这一瞬间 —— 那是**别人的**清理动作,不是本文件要挡的缺陷。 * 只吞 ENOENT:真正的读不动(权限、坏盘)仍然要炸,否则这条防线会缩成一句「什么都没扫到」。 */ function readDirSafe(dir) { try { return fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } } /** 收集本体 md:本体根下的 *.md(去掉运行态那几份)+ `.claude/` 下的全部 md */ function collectBodyMd() { const out = []; const walkClaude = (dir, aid) => { for (const e of readDirSafe(dir)) { const p = path.join(dir, e.name); if (e.isDirectory()) walkClaude(p, aid); else if (e.name.endsWith('.md')) out.push({ aid, file: p }); } }; for (const a of readDirSafe(AGENTS_DIR)) { if (!a.isDirectory()) continue; const adir = path.join(AGENTS_DIR, a.name); for (const e of readDirSafe(adir)) { if (e.isFile() && e.name.endsWith('.md') && !NOT_BODY_MD.has(e.name)) { out.push({ aid: a.name, file: path.join(adir, e.name) }); } } const claude = path.join(adir, '.claude'); if (fs.existsSync(claude)) walkClaude(claude, a.name); } return out; } /** * 抓 `../` 开头的引用。 * 前置负向断言 `(?]*/g; /** * 引用里可能带 `{skill_name}` / `<公司ID>` 这类占位符(使用方替换)。 * 只校验占位符**之前**那一段真实前缀 —— 那一段错了就是深度错了,后面替不替换都救不回来。 */ function staticPrefix(ref) { const segs = ref.split('/'); const i = segs.findIndex((s) => /[{<]/.test(s)); return i < 0 ? ref : segs.slice(0, i).join('/'); } /** * ── 第二类缺陷:**裸相对**交付路径(没有 `../` 前缀,所以上面那条 REF_RE 整类看不见)── * * `workspace/strategy.md` 这种写法在旧布局下是对的(cwd = 本体根目录 = 私有工作区), * 28 号之后 cwd 变成 `system/agents/{aid}/{cid}/{taskId}/` —— 一个 §4.1 明写「一次性、 * 可按保留策略清理」的任务态目录。于是同一份提示词里出现两条互斥指令:坐标声明说 * 「你的交付物必须写到 <公司 workspace 绝对路径>」,正文说「写 `workspace/strategy.md`」。 * **两条都不报错**:裸写照样创建成功,只是落在 agent 自己的任务态目录里, * CTO / COO / org-planner 谁也读不到,下一次清理还会连痕迹一起抹掉。 * * 上面那条 `../` 规则抓不到它,正是因为裸相对路径**根本没有 `../` 可数**。这条补上。 * * 判据是「路径的第一段是不是交付物根」,不是「这行看起来像不像写文件」—— * 后者要靠猜动词,猜漏一个就等于没写这条规则。三个根的选取理由: * workspace / output / outputs —— §4.1 明确规定任务态目录**不建** workspace/、 * 也从来没有 output/,所以以它们开头的裸路径**没有一条**是对的,零误报、无需白名单。 * * 故意**不**收进来的三个根,理由都在「它们在任务态目录里是合法落点或根本不是文件路径」: * logs/ —— §4.1 给任务态目录留的就是 logs/,写在 cwd 下是对的; * artifacts/ —— graph 链路里它是**相对 run 根记录下来的 rel_path**(写进 attempt-*.json * 的字段值,不是 agent 拿去拼 open() 的路径),收进来只会逼出一串白名单; * data/ —— 本体里出现的 `data/` 绝大多数是 `| 直接写 `data/` 下的 sqlite |` 这类**反例表**, * 以及 `data/companies/<公司ID>/runs/…` 这类「从仓库根说全的地址」(读命令用, * 拼错是当场 ENOENT 的响动,不是本文件要挡的静默类)。收进来需要的豁免 * 比规则本身还长,那就不是防线而是装饰。 */ const DELIVERY_ROOTS = ['workspace', 'outputs', 'output']; const BARE_RE = new RegExp( // 前置负向断言排掉「有前缀因而不裸」的三种:`../workspace/`(上面那条规则管)、 // `<你的本体目录>/workspace/` 与 `$W/workspace/`(前面已经有绝对锚点)、以及 `my-workspace/` 这种词中命中。 String.raw`(?*]*`, 'g', ); /** * 两类**不是指令**的行,扫描时跳过: * * 1. `- ✅ [2026-04-09] 成功创建/修改了 2 个文件: workspace/digest/` —— SKILL.md 末尾 * 「经验教训(自动沉淀)」那一段,由跑完的 run 自动追加。它记的是**过去发生过什么**, * 和 todo.md / plan.md 同类(本文件顶上的 NOT_BODY_MD 正是按这个理由排掉那几份的), * 改写它等于伪造历史。它进提示词只是噪声,不会被当成落点指令。 * 2. `⚠️ 别写相对路径 `workspace/...`` —— 正文在教节点**不要**这么写。`...` 三个点不可能是 * 真文件名,把这个 token 当反面例句放行,比让 workgraph-planning 把话说抽象要好: * 那三处反例本来就是上一轮为了挡同一个缺陷才写进去的。 */ const AUTO_LESSON_LINE = /^\s*[-*]\s*[✅❌⚠️]\s*\[\d{4}-\d{2}-\d{2}\]/; const COUNTEREXAMPLE_REF = 'workspace/...'; /** 单行取裸相对交付路径 —— 单独抽出来是为了让规则本身能被用例直接证伪 */ function bareRefsInLine(line) { if (AUTO_LESSON_LINE.test(line)) return []; return (line.match(BARE_RE) || []) // 反例先排、再削尾标点:顺序反过来的话 `workspace/...` 会被削成 `workspace/`,反例就漏出来了 .filter((raw) => raw !== COUNTEREXAMPLE_REF) .map((m) => m.replace(/[.,)\]`]+$/, '')); } function collectBareRefs() { const rows = []; for (const { aid, file } of collectBodyMd()) { let text; try { text = fs.readFileSync(file, 'utf-8'); } catch (e) { if (e.code === 'ENOENT') continue; throw e; } text.split('\n').forEach((line, i) => { for (const ref of bareRefsInLine(line)) { rows.push({ aid, ref, where: `${path.relative(ROOT, file)}:${i + 1}` }); } }); } return rows; } function collectRefs() { const rows = []; for (const { aid, file } of collectBodyMd()) { let text; try { text = fs.readFileSync(file, 'utf-8'); } catch (e) { if (e.code === 'ENOENT') continue; // 同 readDirSafe:列到名字之后才被别的用例删掉 throw e; } text.split('\n').forEach((line, i) => { for (const m of line.match(REF_RE) || []) { const ref = m.replace(/[.,)\]`]+$/, ''); const prefix = staticPrefix(ref); if (!prefix) continue; rows.push({ aid, ref, prefix, where: `${path.relative(ROOT, file)}:${i + 1}`, upCount: (ref.match(/\.\.\//g) || []).length, // 静态前缀只剩 `../../` 这种纯上跳:占位符紧跟在上跳后面(`../../../<对方的 agent-id>/todo.md`), // 或正文就是在讲深度常量本身(「`../../../` 即 system/agents/」)。没有具体落点可查存在性, // 但**跳了几级**仍然是可证伪的 —— 见下面那条「不许跳出仓库」。 pureUp: /^(\.\.\/)+$/.test(prefix), target: path.relative(ROOT, path.resolve(probeCwd(aid), prefix)), }); } }); } return rows; } describe('本体 md 的相对引用(cwd 深度 3)', () => { const refs = collectRefs(); it('确实扫到了本体与引用(防止目录改名后测试静默空跑)', () => { expect(collectBodyMd().length).toBeGreaterThan(50); expect(refs.length).toBeGreaterThan(20); }); it('深度基准本身钉死:本体在 cwd 的 `../../`,不是 `../../../`', () => { // 这一条是全文件的地基。`_base/agent.md` 教所有新 agent 怎么找自己的人格文件, // 多写一级就指到 system/agents/ 本身 —— 存在、可列目录、但没有 soul.md。 const cwd = probeCwd('_base'); expect(fs.existsSync(path.resolve(cwd, '../../soul.md'))).toBe(true); expect(fs.existsSync(path.resolve(cwd, '../../../soul.md'))).toBe(false); expect(path.relative(cwd, path.join(AGENTS_DIR, '_base'))).toBe(path.join('..', '..')); }); it('每条跨越本体层的引用都解析到真实存在的目标', () => { const missing = refs .filter((r) => !r.pureUp) .filter((r) => r.upCount >= 2) .filter((r) => !(r.target in PENDING_WRITE_TARGETS)) .filter((r) => !fs.existsSync(path.join(ROOT, r.target))); expect( missing.map((m) => `${m.where} ${m.ref} → ${m.target} 不存在`), ).toEqual([]); }); it('没有一条引用跳出仓库(上一条查不了的纯上跳引用,唯一还能查的就是它跳了几级)', () => { // 为什么单开一条:`../../../<对方的 agent-id>/todo.md` 这种「上跳后面紧跟占位符」的写法, // 静态前缀只剩纯上跳,上面那条存在性断言**整条跳过**——也就是说改造时把它多写一级或少写一级, // 全文件没有任何一处会吭声。而深度写多了必然跳出仓库根(`../../../../../../` 起就出界), // 这是纯上跳引用身上仅剩的、仍然可证伪的性质。少写一级抓不到,认这个上限。 const escaped = refs.filter((r) => r.target.startsWith('..')); expect( escaped.map((e) => `${e.where} ${e.ref} 跳到仓库外:${e.target}`), ).toEqual([]); }); it('`../` 那一层只准出现公司态目录自己的东西', () => { const strays = refs .filter((r) => !r.pureUp) .filter((r) => r.upCount === 1) .filter((r) => !COMPANY_STATE_ENTRIES.has(r.prefix.split('/')[1])); expect( strays.map((s) => `${s.where} ${s.ref} 不是公司态目录(${[...COMPANY_STATE_ENTRIES].join(' / ')})里的东西`), ).toEqual([]); }); it('规则本身可证伪:裸的抓得到,带锚点的不误伤', () => { // 没有这一条,正则写坏(比如某天把 `/` 前置断言改宽)就会退化成「一条都扫不出来」, // 而「扫不出来」和「全都合格」在断言里长得一模一样 —— 上一轮漏掉这类缺陷就是这么发生的。 expect(bareRefsInLine('产出一份 `workspace/strategy.md`:')).toEqual(['workspace/strategy.md']); expect(bareRefsInLine('- 所有自主创建的文件仅限在 workspace/ 内操作')).toEqual(['workspace/']); expect(bareRefsInLine('见 `../../../../../data/companies/c/workspace/x.md`')).toEqual([]); expect(bareRefsInLine('写到 `$W/workspace/x.md`')).toEqual([]); expect(bareRefsInLine('落在 `<你的本体目录>/workspace/hunt/`')).toEqual([]); expect(bareRefsInLine('- ✅ [2026-04-09] 成功创建/修改了 1 个文件: workspace/digest_v2/`')).toEqual([]); }); /** * `AgentScaffold` 的 bootstrap 模板(`AgentScaffold.js` 与 * `packages/agent-core/base/common/tools-base.md`)曾经写着「所有自主创建的文件仅限在 * workspace/ 内操作」—— 裸相对路径,28 号之后 cwd 是任务态目录,照这句写出来的交付物 * 会静默丢失。2026-08-23(36 号文档阶段二试验时顺手撞见并修掉):模板与 * `AgentScaffold.js` 都已改成「写到运行时坐标给出的绝对路径」,新脚手架不会再带这个缺陷。 * * 但 `zz-scaffold-readonly-fresh` 是 `scaffold-body-readonly.test.js` 依赖的**冻结夹具** * ——28 号裁决「测试建出来的本体不删」,且它的存在与否本身就是那份测试的断言载体 * (建/判重两分支),所以它永远停在被建出来那一刻的旧模板内容上,不会随模板修好而更新。 * 登记成 (文件, 引用) 二元组而不是「tools.md 一律放行」:别的本体的 tools.md * 再出现同一行照样红。 */ const FROZEN_FIXTURE_DEFECTS = new Set([ 'system/agents/zz-scaffold-readonly-fresh/tools.md::workspace/', ]); const defectKey = (b) => `${b.where.split(':')[0]}::${b.ref}`; it('没有一条裸相对交付路径(cwd 是会被清理的任务态目录,写进去等于没交付)', () => { const bare = collectBareRefs().filter((b) => !FROZEN_FIXTURE_DEFECTS.has(defectKey(b))); expect( bare.map((b) => `${b.where} ${b.ref} 裸相对 → 落进 cwd(任务态目录,一次性、会被清理、同事看不见)`), ).toEqual([]); }); it('豁免名单里没有过期条目(引用都删了还留着白名单 = 下次悄悄放行别的错)', () => { const referenced = new Set(refs.map((r) => r.target)); expect(Object.keys(PENDING_WRITE_TARGETS).filter((t) => !referenced.has(t))).toEqual([]); }); });