migrate-agent-registry.js
20 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
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
#!/usr/bin/env node
/**
* migrate-agent-registry.js — 28 号设计的存量数据迁移(docs/done/system-design/28-agent-registry-unification.md §七)
*
* 幂等,分步执行。M1(本体归位)已由批次 1 的 git mv 完成;本文件实现 M-global(批次 3)
* 与 M2(公司实例拆解,批次 4)。M3(历史 run)按 §七原地留档,不搬。
*
* ── M-global:全局 agent 的运行态目录下沉到 `_global/` ──
*
* 批次 3 把记忆 / 会话的落点从「本体根」改成「公司态目录」`system/agents/{aid}/{cid}/`,
* 不属于任何公司的调用(master、agent-creator、innovator-* …)落保留公司 id `_global`(§5.1)。
* 代码改完那一刻,存量的 `system/agents/master/conversations/` 就**读不到了** —— 表现是
* 「历史对话空了」而不是报错,所以搬迁必须跟着代码一起落,不能拖到下一批。
*
* 搬哪些:`conversations` 与 `memory`。**只搬这一批代码真的改了落点的那两个。**
* - `logs/` 不搬:它的写侧与读侧仍写死在本体根上(agent-chat.js 的 billing 读
* `system/agents/master/logs`,同文件的写侧也是同一个路径)。搬过去等于历史数据被孤立、
* 代码转头又把老目录建回来 —— 半迁移比不迁移更难查。它的归宿是任务态 `{taskId}/logs/`(§4.1),
* 跟着后续批次一起动。
* - `workspace/` 不搬:`workspace/_lessons.md` 的读侧(agent-chat.buildSystemPrompt)还在本体根上,
* 理由同上。
*
* 搬之前整棵备份到 `data/_migration-backup-agentdirs/`(§七「回滚」:原目录快照 + 只加列不删列)。
*
* ── M2:公司实例拆解(`data/companies/<cid>/agents/<aid>` → 本体 + 公司态)──
*
* 17 个存量目录,按**本体在不在**分两类(§七的 REF / COPY 之分只影响报告措辞,
* 处理动作是同一套 —— REF 无非是「私有 agent.md 恰好不存在」的 COPY):
*
* 本体已存在 → MOUNT:公司态文件(company-context.md / user.md / memory / conversations)
* 搬进 `system/agents/{aid}/{cid}/`,本体副本丢弃。
* 私有 `agent.md` 先与本体逐字节 diff:一致直接丢;不一致说明它在运行中被
* agent 自己改过,落成 `agent.md.local` 并在报告里点名,等人工裁决 ——
* 直接丢会**静默**抹掉一次演化,直接盖会污染所有公司看到的本体。
* 本体不存在 → GRADUATE(毕业):本体文件搬去 `system/agents/{aid}/`,公司态搬去
* `system/agents/{aid}/{cid}/`,system_agents 那行改 origin='created'。
* §2.4 的 org-coder / org-planner 就是这类「永远无法被第二家公司复用」的死角。
*
* 认不出来的东西(venv/ *.py/ train/ …)一律**原样搬进公司态目录**并在报告里列出。
* 白名单之外就删,等于用一条 rm 换一份「不知道丢了什么」的报告。
*
* ── 例外:`workspace/` 绝不进 agent 树(二轮修复,原 major 3)──
*
* 上面那条「认不出来就搬进公司态目录」把公司的**交付物**也搬进了
* `system/agents/{aid}/{cid}/workspace/`,违反 §5.4「产物有且只有一个家」——
* 而 §5.5 的新 gitignore 又把「本体下的深度-2 子目录」整体隐藏,于是这次误搬
* **完全静默**:git status 干净、报告只写一行「非骨架条目原样搬入」,
* 没有任何人会发现 idearun 的 drama_script 从此只存在于 youtube-crawler 的私有树里。
* 交付物的家是 `data/companies/{cid}/workspace/`,`reconcileWorkspace()` 负责送回去。
*
* 每个被处理的 (公司, 角色) 都补一行 `company_agents`(雇佣关系的新真相源,§4.2)。
* 幂等:原目录处理完即删除,第二遍跑是零命中;备份目录已存在就不再覆盖
* (否则第二遍会拿**迁移后**的空状态把备份盖掉,回滚路径当场消失)。
*
* 用法:
* node scripts/migrate-agent-registry.js --dry-run
* node scripts/migrate-agent-registry.js
*/
const fs = require('fs');
const path = require('path');
const locator = require('../server/services/agent-locator');
const DRY = process.argv.includes('--dry-run');
const BACKUP_ROOT = path.join(locator.REPO_ROOT, 'data', '_migration-backup-agentdirs');
/** 落点被批次 3 改掉的运行态目录(改动理由见文件头) */
const MOVED_DIRS = ['conversations', 'memory'];
function log(...args) { console.log(DRY ? '[dry-run]' : '[migrate]', ...args); }
function migrateGlobalRuntimeDirs() {
const stats = { agents: 0, dirs: 0, skipped: 0 };
if (!fs.existsSync(locator.AGENTS_ROOT)) return stats;
for (const entry of fs.readdirSync(locator.AGENTS_ROOT, { withFileTypes: true })) {
// `_base` / `.claude` 这类保留项不是可调用 agent(§5.1)
if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
const aid = entry.name;
const globalDir = locator.companyDir(aid); // = system/agents/{aid}/_global
let movedForThisAgent = 0;
for (const name of MOVED_DIRS) {
const src = path.join(locator.bodyDir(aid), name);
if (!fs.existsSync(src)) continue;
const dest = path.join(globalDir, name);
if (fs.existsSync(dest)) {
// 两边都在:不合并 —— 合并会在同名 convId 上悄悄挑一份留下。人工裁决。
log(`⚠️ ${aid}/${name}: 新旧两处都存在,跳过(请人工合并 ${src} → ${dest})`);
stats.skipped++;
continue;
}
log(`${aid}/${name} → _global/${name}`);
if (!DRY) {
const backup = path.join(BACKUP_ROOT, aid, name);
fs.mkdirSync(path.dirname(backup), { recursive: true });
fs.cpSync(src, backup, { recursive: true, force: true });
fs.mkdirSync(globalDir, { recursive: true });
fs.renameSync(src, dest);
}
stats.dirs++;
movedForThisAgent++;
}
if (movedForThisAgent) stats.agents++;
}
return stats;
}
// ══════════════════════════════════════════════════════════════════
// M2:公司实例拆解
// ══════════════════════════════════════════════════════════════════
// 备份用固定时间戳:脚本可重复跑,用 Date.now() 会每跑一次多一份快照,
// 而「幂等」的意思是第二遍什么都不该发生。
const M2_BACKUP = path.join(locator.REPO_ROOT, 'data', '_migration-backup-2026-08-17');
const COMPANIES_DIR = path.join(locator.REPO_ROOT, 'data', 'companies');
const REPORT_PATH = path.join(M2_BACKUP, 'M2-report.md');
/**
* 写库用的固定时间戳,理由同 M2_BACKUP:`new Date().toISOString()` 会让第二遍空跑
* 也把 system_agents.updated_at 改一遍,于是「幂等 = 第二遍什么都不该发生」没法被验证 ——
* 谁都分不清那行是这次改的还是上次改的。迁移时刻是常量,写死成迁移日期。
*/
const MIGRATED_AT = '2026-08-17T00:00:00.000Z';
/** 公司态:归属 (agentId, companyId),搬进 `system/agents/{aid}/{cid}/` */
const COMPANY_STATE_ENTRIES = new Set(['company-context.md', 'user.md', 'memory', 'conversations']);
/**
* 本体副本:雇佣时从模板拷进私有目录的那批,本体里已经有一份,丢弃。
* `user.md` **不在**这里 —— 它虽然也是模板文件,但雇佣时被整份重写成汇报关系,是公司态。
* `agent.md` 也不在这里:它要先 diff(见文件头)。
*/
const BODY_COPY_ENTRIES = new Set([
'soul.md', 'identity.md', 'tools.md', 'heartbeat.md', 'plan.md', 'todo.md',
'cron.json', 'config.json', 'SKILL.md', '.claude', 'skills',
]);
/**
* P1 之前的 `_injectCompanyContext` 会把「## 所属公司」整段**追加**到私有 agent.md 末尾。
* 那段是公司语境、不是 agent 的演化,diff 时必须先剥掉 —— 不剥的话 6 个 COPY 目录
* 全部报「与本体不一致」,人工裁决清单里 4 条是噪声,真正改过的那两个反而淹没在里面。
*/
const COMPANY_BLOCK_RE = /\n*-{3}[ \t]*\n+## 所属公司[\s\S]*$/;
function agentMdBody(p) {
return fs.readFileSync(p, 'utf-8').replace(COMPANY_BLOCK_RE, '').trimEnd();
}
/** 私有 agent.md 剥掉公司语境段之后,与本体是否逐字一致 */
function sameAgentMd(priv, body) {
try { return agentMdBody(priv) === agentMdBody(body); } catch { return false; }
}
/** 报告里用仓库相对路径,绝对路径既长又换台机器就不对 */
const rel = (p) => path.relative(locator.REPO_ROOT, p);
/** 搬一个条目到 destDir;目标已存在则不合并(合并会在同名文件上悄悄挑一份留下),交人工 */
function moveEntry(srcPath, destDir, destName, notes) {
const dest = path.join(destDir, destName);
if (fs.existsSync(dest)) {
notes.push(`⚠️ 目标已存在,未搬动:\`${rel(dest)}\`(请人工合并 \`${rel(srcPath)}\`)`);
return false;
}
if (!DRY) {
fs.mkdirSync(destDir, { recursive: true });
fs.renameSync(srcPath, dest);
}
return true;
}
/** 交付物的唯一归宿(§5.4 / G2b):公司侧 workspace,不是 agent 本体树 */
const companyWorkspace = (cid) => path.join(COMPANIES_DIR, cid, 'workspace');
/** 一棵目录里的所有文件,相对 root 的路径 */
function walkFiles(root, prefix = '') {
const out = [];
for (const e of fs.readdirSync(root, { withFileTypes: true })) {
const relPath = prefix ? path.join(prefix, e.name) : e.name;
if (e.isDirectory()) out.push(...walkFiles(path.join(root, e.name), relPath));
else out.push(relPath);
}
return out;
}
const sameBytes = (a, b) => fs.readFileSync(a).equals(fs.readFileSync(b));
/**
* 把误落在 agent 树里的一棵 workspace 送回公司树。逐**文件**比对而不是整目录搬,
* 因为公司树的 workspace 一定已经存在 —— 整目录 rename 会直接撞上 moveEntry 的
* 「目标已存在就不动」,等于什么也没修。
*
* 三分支,内容不同的那支是关键:两边都有且字节不同说明它们**已经分叉**
* (实测 NdWIjjRjWKQ_content.json 两份连 schema 都不是一个),自动挑一份留下
* 就是替人做了一次无法回溯的裁决。隔离到 `_conflict-from-agent-tree/{aid}/` 并点名,
* 让人来判 —— 这和文件头 agent.md.local 的处置是同一条原则。
*/
function reconcileWorkspace(srcWs, cid, aid, notes) {
const destWs = companyWorkspace(cid);
if (!fs.existsSync(path.join(COMPANIES_DIR, cid))) {
// 没有公司树就没有「交付物的家」。造一个出来等于凭空发明一家公司,宁可留在原地点名。
notes.push(`⚠️ 公司目录不存在,workspace 留在原地待人工处理:\`${rel(srcWs)}\``);
return;
}
const quarantine = path.join(destWs, '_conflict-from-agent-tree', aid);
for (const relFile of walkFiles(srcWs)) {
const src = path.join(srcWs, relFile);
const dest = path.join(destWs, relFile);
if (!fs.existsSync(dest)) {
if (!DRY) { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.renameSync(src, dest); }
notes.push(`↩️ 交付物送回公司树:\`${rel(dest)}\``);
} else if (sameBytes(src, dest)) {
if (!DRY) fs.rmSync(src, { force: true });
} else {
const q = path.join(quarantine, relFile);
if (!DRY) { fs.mkdirSync(path.dirname(q), { recursive: true }); fs.renameSync(src, q); }
notes.push(`❗ 与公司树同名但内容已分叉,隔离为 \`${rel(q)}\`,**需人工裁决**`);
}
}
// 只删空壳;还有东西说明上面哪一步没搬动,留着给人看(DRY 下什么都没搬,一定非空)
if (!DRY && fs.existsSync(srcWs) && !walkFiles(srcWs).length) {
fs.rmSync(srcWs, { recursive: true, force: true });
}
}
/**
* 修既成事实:扫每个本体下「公司态目录里的 workspace」,把上一版脚本误搬进来的交付物送回去。
* 幂等 —— 送完目录就没了,第二遍是零命中。
*/
function repairAgentTreeWorkspaces() {
const rows = [];
if (!fs.existsSync(locator.AGENTS_ROOT)) return rows;
for (const aEntry of fs.readdirSync(locator.AGENTS_ROOT, { withFileTypes: true })) {
if (!aEntry.isDirectory() || aEntry.name.startsWith('.')) continue;
const aid = aEntry.name;
for (const cEntry of fs.readdirSync(path.join(locator.AGENTS_ROOT, aid), { withFileTypes: true })) {
// `_global` 不对应任何公司,没有公司 workspace 可送 —— 它的东西本来就该留在本体树
if (!cEntry.isDirectory() || locator.isReservedCompanyId(cEntry.name)) continue;
const srcWs = path.join(locator.AGENTS_ROOT, aid, cEntry.name, 'workspace');
if (!fs.existsSync(srcWs)) continue;
const notes = [];
reconcileWorkspace(srcWs, cEntry.name, aid, notes);
log(`WORKSPACE ${cEntry.name}/${aid}`);
for (const n of notes) log(` ${n}`);
rows.push({ cid: cEntry.name, aid, kind: 'WORKSPACE', notes });
}
}
return rows;
}
/** 一个 `data/companies/{cid}/agents/{aid}` 目录的处理 */
function migrateCompanyAgentDir(cid, aid, srcDir) {
const bodyPath = locator.bodyDir(aid);
const bodyAgentMd = path.join(bodyPath, 'agent.md');
const hasBody = fs.existsSync(bodyAgentMd);
const privAgentMd = path.join(srcDir, 'agent.md');
const kind = hasBody ? (fs.existsSync(privAgentMd) ? 'COPY' : 'REF') : 'GRADUATE';
const stateDir = locator.companyDir(aid, cid);
const notes = [];
for (const name of fs.readdirSync(srcDir)) {
const src = path.join(srcDir, name);
if (COMPANY_STATE_ENTRIES.has(name)) {
moveEntry(src, stateDir, name, notes);
continue;
}
if (name === 'agent.md') {
if (!hasBody) {
moveEntry(src, bodyPath, name, notes); // 毕业:私有 agent.md 就是本体
} else if (sameAgentMd(src, bodyAgentMd)) {
if (!DRY) fs.rmSync(src, { force: true });
} else {
// 剥掉公司语境段之后仍然不一致 = 运行中被改过:留证据 + 点名,不自动裁决
if (moveEntry(src, stateDir, 'agent.md.local', notes)) {
notes.push(`❗ 私有 agent.md 与本体不一致,已保留为 \`${rel(path.join(stateDir, 'agent.md.local'))}\`,**需人工裁决**`);
}
}
continue;
}
// 交付物:直连公司树,**不**经过 agent 树中转(见文件头「例外」)
if (name === 'workspace') {
reconcileWorkspace(src, cid, aid, notes);
continue;
}
if (BODY_COPY_ENTRIES.has(name)) {
if (hasBody) {
if (!DRY) fs.rmSync(src, { recursive: true, force: true }); // 本体里已有同名文件
} else {
moveEntry(src, bodyPath, name, notes); // 毕业:这批就是新本体的骨架
}
continue;
}
// 白名单之外:认不出来就原样搬进公司态目录,绝不删
moveEntry(src, stateDir, name, notes);
notes.push(`ℹ️ 非骨架条目 \`${name}\` 原样搬入公司态目录`);
}
// 空了才删 —— 还有东西说明上面有条目没搬动(目标已存在),留着给人看
const leftover = fs.existsSync(srcDir) ? fs.readdirSync(srcDir) : [];
if (!DRY && !leftover.length) fs.rmSync(srcDir, { recursive: true, force: true });
else if (leftover.length) notes.push(`⚠️ 原目录未清空,残留:${leftover.join(', ')}`);
return { cid, aid, kind, notes };
}
function migrateCompanyInstances() {
const rows = [];
if (!fs.existsSync(COMPANIES_DIR)) return rows;
// 快照整棵 data/companies/(§七「回滚」)。已存在就不覆盖,见文件头「幂等」。
if (!DRY && !fs.existsSync(M2_BACKUP)) {
fs.mkdirSync(M2_BACKUP, { recursive: true });
fs.cpSync(COMPANIES_DIR, path.join(M2_BACKUP, 'companies'), { recursive: true });
log(`已快照 data/companies/ → ${M2_BACKUP}/companies`);
}
// 雇佣关系落库:company_agents.system_agent_id 是指向 system_agents 的外键,
// 先 ensureSystemAgentRow 补本体行,再插关系,否则外键直接挂。
const { getDb } = require('../server/services/graph-db');
const { ensureSystemAgentRow } = require('../server/services/graph-service');
const db = DRY ? null : getDb();
for (const cEntry of fs.readdirSync(COMPANIES_DIR, { withFileTypes: true })) {
if (!cEntry.isDirectory()) continue;
const agentsDir = path.join(COMPANIES_DIR, cEntry.name, 'agents');
if (!fs.existsSync(agentsDir)) continue;
for (const aEntry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (!aEntry.isDirectory()) continue;
const row = migrateCompanyAgentDir(cEntry.name, aEntry.name, path.join(agentsDir, aEntry.name));
log(`${row.kind.padEnd(8)} ${row.cid}/${row.aid}`);
for (const n of row.notes) log(` ${n}`);
if (db) {
ensureSystemAgentRow(row.cid, row.aid, row.aid);
if (row.kind === 'GRADUATE') {
// 毕业的本体是公司自己养出来的,不是仓库自带(§七 M2 第三类)
db.prepare(`UPDATE system_agents SET origin = 'created', updated_at = ? WHERE id = ?`)
.run(MIGRATED_AT, row.aid);
}
db.prepare(`
INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
VALUES (?, ?, ?, 'active', ?)
`).run(row.cid, row.aid, row.aid, MIGRATED_AT);
}
rows.push(row);
}
// agents/ 本身也要消失(验收 1:`ls data/companies/*/agents/` 为空)
if (!DRY && fs.existsSync(agentsDir) && !fs.readdirSync(agentsDir).length) {
fs.rmSync(agentsDir, { recursive: true, force: true });
}
}
return rows;
}
function writeReport(rows, title = 'M2 迁移报告 —— 公司实例拆解', reportPath = REPORT_PATH) {
const lines = [
`# ${title}`,
'',
`- 脚本: \`scripts/migrate-agent-registry.js\`(28 号设计 §七 M2)`,
`- 快照: \`data/_migration-backup-2026-08-17/companies\``,
`- 处理目录数: ${rows.length}`,
'',
'| 公司 | 角色 | 类型 | 备注 |',
'|---|---|---|---|',
...rows.map((r) => `| ${r.cid} | ${r.aid} | ${r.kind} | ${r.notes.join('<br>') || '—'} |`),
'',
'类型说明:REF = 无私有 agent.md 的引用式雇佣;COPY = 有私有 agent.md 且本体已存在;',
'GRADUATE = 本体不存在,私有副本毕业成 `system/agents/{id}/` 的新本体。',
'WORKSPACE = 误落 agent 本体树的交付物,已送回 `data/companies/{cid}/workspace/`。',
'',
'标 ❗ 的行需要人工裁决:私有 `agent.md` 与本体不一致(保留为 `agent.md.local`),',
'或交付物与公司树同名却已分叉(隔离在 `_conflict-from-agent-tree/`)。',
'',
];
if (!DRY) {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, lines.join('\n'), 'utf-8');
}
console.log('\n' + lines.join('\n'));
}
const stats = migrateGlobalRuntimeDirs();
log(`M-global 完成:${stats.agents} 个 agent / ${stats.dirs} 个目录搬到 _global/,跳过 ${stats.skipped} 个`);
if (!DRY && stats.dirs) log(`备份在 ${BACKUP_ROOT}`);
const m2Rows = migrateCompanyInstances();
// 修既成事实:上一版脚本已经把一批交付物搬进 agent 树了,光改分类逻辑追不回来。
// 放在 M2 之后跑,这样同一次调用里「刚被 M2 搬错的」和「历史遗留的」都会被扫到。
const wsRows = repairAgentTreeWorkspaces();
log(`M2 完成:处理 ${m2Rows.length} 个公司私有 agent 目录,回收 ${wsRows.length} 棵误落 agent 树的 workspace`);
if (m2Rows.length) writeReport(m2Rows);
// 单独一份报告:M2 早已跑完,wsRows 非空而 m2Rows 为空是常态,
// 合进同一个文件会拿一份两行的表把原始 M2-report.md 覆盖掉。
if (wsRows.length) {
writeReport(wsRows, '交付物归位报告 —— workspace 回收', path.join(M2_BACKUP, 'WORKSPACE-repair-report.md'));
}