#!/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//agents/` → 本体 + 公司态)── * * 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('
') || '—'} |`), '', '类型说明: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')); }