agent-md-refs.test.js
17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
/**
* 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-<ts>`,见 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;
}
/**
* 抓 `../` 开头的引用。
* 前置负向断言 `(?<![.\w])` 是必须的:正文里的省略号 `.../data/companies/` 和 URL 里的
* `https://i.ytimg.com/vi/.../default.jpg` 都含 `../`,不排掉的话噪声会淹掉真信号。
*/
const REF_RE = /(?<![.\w])(?:\.\.\/)+[A-Za-z0-9_\-./{}<>]*/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`(?<![\w./~-])(?:${DELIVERY_ROOTS.join('|')})\/[A-Za-z0-9_\-./{}<>*]*`,
'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([]);
});
});