/** * Agent 目录寻址的**唯一**推导实现(28 号设计 §2.5 / §5.1)。 * * 为什么单独一个模块:改造前这套路径拼接在 6 个文件里各写了一份 * (agent-manager 执行寻址、agent-chat 会话读写、memory-service 记忆、heartbeat 心跳、 * agent-transcript 日志回溯、graph-engine 的 resolveAgentName)。它们算的是同一个 * 目录,但只有 agent-manager 那份实现了 P1 的 systemAgentRef 分支 —— 也就是说 * 「同一个 agent 在执行时用共享本体、在会话读写时用私有目录」这种分叉,是靠六份 * 手抄的字符串拼接来维持一致的。company-paths.js 的注释已经记过一次这个教训: * **写的一侧与读的一侧必须算出同一个字符串,算不出同一个就是索引指向空气且不报错** * —— 目录还在、代码也没抛,只是两边说的不是一个地方。六份手抄比两份更没救。 * * 布局(§4.1): * system/agents/{aid}/ 本体,只读,全系统唯一一份 * system/agents/{aid}/{cid}/ 公司态(长期:company-context.md / user.md / memory / conversations) * system/agents/{aid}/{cid}/{taskId}/ 任务态(CLI 的 cwd:logs / scratch / result.json) * * 公司态与本体自带的子目录**同层**,所以公司 id 不能撞 RESERVED_SUBDIRS —— 见下。 * * 注意本模块只负责 `system/` 这一侧。**产物**(公司 workspace / run artifacts / task 交付物) * 归 company-paths.js 推导,两个根故意分开:产物有且只有一个家(§5.4)。 */ const fs = require('fs'); const path = require('path'); const REPO_ROOT = path.resolve(__dirname, '../..'); const AGENTS_ROOT = path.join(REPO_ROOT, 'system', 'agents'); /** * 不属于任何公司的调用(master / agent-creator / innovator-* 等)落这个保留公司 id。 * 用保留值而不是「省掉这一层」:否则「有没有公司」就是两条深度不同的代码路径, * 而深度不同意味着 agent.md 里的相对引用(`../../../soul.md`)要分两种写法。 */ const GLOBAL_COMPANY_ID = '_global'; /** taskId 缺省的落点,语义同 company-paths.taskDirRel 的 `_no-task`:宁可全归到一个显眼的坑里 */ const NO_TASK_ID = '_no-task'; /** * 本体自带的、会和公司 id 撞名的深度-2 目录名。 * * 这份名单是**扫每个本体目录下真实存在的子目录**得来的,不是照抄设计稿: * 实测多出了 `code` / `system`(scrapling_crawler、weibo-scraper 这类带代码的本体), * 设计稿列的 `knowledge` / `uploads` / `data` 眼下没有实例但仍收进来 —— 它们是 * agent-chat 的文件端点会创建的目录名,晚一点出现而已。 * 下划线 / 点开头的(`__pycache__`、`.claude`)由 isReservedCompanyId 的前缀规则覆盖。 */ const RESERVED_SUBDIRS = new Set([ 'agent_data', 'code', 'conversations', 'data', 'docs', 'knowledge', 'logs', 'memory', 'system', 'tasks', 'train', 'uploads', 'workspace', ]); /** 本体目录:只读,全系统唯一一份 */ function bodyDir(agentId) { return path.join(AGENTS_ROOT, agentId); } /** 公司态目录:不传 companyId 走保留值 `_global` */ function companyDir(agentId, companyId) { return path.join(bodyDir(agentId), companyId || GLOBAL_COMPANY_ID); } /** 任务态目录 = CLI 的 cwd */ function taskDir(agentId, companyId, taskId) { return path.join(companyDir(agentId, companyId), taskId || NO_TASK_ID); } /** * 「本体定义齐全」的判据(28 批次 5-23:agent-creator 的产出契约)。 * * 为什么需要这份名单:新本体是 `_base` 骨架的拷贝 + agent-creator 一次 * **fire-and-forget** 的异步填充。填没填、填全没填全,改造前**没有任何一处会问** —— * §2.4 的 org-coder 就是这么变成空定义的:目录在、config.json 在、库里也有行, * 只有 agent.md 还是通用 Base Agent 文本,跑起来静默跑偏且不报错。 * * 三个文件对应提示词装配的三条真实到达路径,缺一条就是一种静默降级: * agent.md PromptBuilder 唯一必装的那份(§2.6) * soul.md agent 运行时按相对路径自读的人格定义 * .claude/skills/main_skill/SKILL.md CLI 侧唯一会被自动发现的技能入口 * 非空判据用「去掉空白后还有内容」而不是「文件存在」:脚手架会先建空文件占位。 * * ⚠️ **SKILL.md 是「存在才查」,不是必需件 —— 这条是按仓库实测现状定的,不是拍脑袋放宽。** * 实测(2026-08-18,28 三轮复验):`system/agents/` 下 56 个带 agent.md 的本体里, * 只有 2 个(claude-youtube-skills / digester-youtube-crawler)带 * `.claude/skills/main_skill/SKILL.md`。也就是说这份文件从来不是本体的组成部分, * 而是「这个 agent 顺带发布了一个技能」的可选产物 —— 把它算进必需件,等于宣布 * **54/56 的在库本体都没资格上岗**。闸门只作用于 draft / training 两个状态,所以大多数 * 本体只是侥幸没被卡;真被卡死的是 `code-planner`(config.json 写着 status='training', * seedAgents 原样入库),它是 p2-verify 在跑的现役 work 层本体,而**任何新建的公司 * 都再也雇不到它** —— 老公司只是靠 company_agents 已有行走 `already_exists` 分支绕过去了。 * * 所以判据拆成两档: * 必需(REQUIRED):agent.md + soul.md —— 缺失 / 空白 / 仍带哨兵都算缺; * 可选(OPTIONAL):SKILL.md —— **不存在不算缺**;存在则必须是填过的(空白或仍带哨兵算缺)。 * 「存在才查」而不是「整条删掉」:`_base` 骨架里这份是带哨兵的,删掉判据就等于允许 * 「拷完骨架、只把 agent.md/soul.md 填了,SKILL.md 还是通用骨架」的本体上岗, * 而 CLI 会把那份骨架技能真的当技能装配进去。 */ const REQUIRED_BODY_DEFINITION_FILES = ['agent.md', 'soul.md']; const OPTIONAL_BODY_DEFINITION_FILES = [path.join('.claude', 'skills', 'main_skill', 'SKILL.md')]; /** 两档合并后的完整清单,顺序即报「缺什么」时的顺序(必需在前) */ const BODY_DEFINITION_FILES = [...REQUIRED_BODY_DEFINITION_FILES, ...OPTIONAL_BODY_DEFINITION_FILES]; /** * `_base` 骨架三份定义文件里都埋着这一行;**它还在 = 这份定义是通用骨架,没被填过**。 * * 为什么非要这个标记:补齐 `_base`(让它真带上 soul.md + SKILL.md,见 base-skeleton-complete) * 与「非空即齐全」的判据凑在一起,会把闸门整个抵消 —— 骨架一复制完三份文件就都非空, * `promoteDraftIfComplete` 当场转正,新角色**刚拷完通用模板就上岗**,身上零角色特化, * 第二家公司还会把它当成一份已就绪的可复用本体直接雇走。那正是 §2.4 的 org-coder * 失败形态,且污染面从 1 家公司放大成全系统。 * * 为什么是文件内的标记而不是 DB 列 / config 标志位:判据的真相来源必须**只有文件内容本身**。 * agent-creator 是一次 fire-and-forget 的自调用,没有「写完了」这个回调点可挂; * 它无论用什么方式写文件,只要真写了就自然把这行覆盖掉,不会出现「文件填好了但标志位忘了改」 * 或反过来的不一致 —— 而那种不一致恰恰是没人会去查的那一种。 */ const BASE_SKELETON_SENTINEL = ''; /** * 这份本体还缺哪些定义文件 —— 按**本体目录**判,不经过 agentId 推导。 * * 单独开这个入口是给 graph-seed-service 用的:它的扫描根是 `GRAPH_SEED_ROOT` * (单测把它指向临时目录),而 bodyDir() 恒指向真实仓库。用 agentId 版本的话, * 夹具里的 `org-planner` 会去查真仓库里的同名本体 —— 判据算的不是同一个目录, * 而且不报错。 * * 「缺」有三种形态,对上岗资格是同一回事:**读不到**(必需件才算)、**空白** * (脚手架建了占位文件)、**仍带哨兵**(从 `_base` 拷来还没被 agent-creator 填过)。 * * @param {string} dir 本体目录的绝对路径 * @returns {string[]} 缺失的相对路径,空数组 = 齐全,可以从 draft/training 升 active */ function missingBodyDefinitionIn(dir) { return BODY_DEFINITION_FILES.filter((rel) => { let text; try { text = fs.readFileSync(path.join(dir, rel), 'utf-8'); } catch { // 读不到(不存在 / 是目录 / 无权限):必需件算缺,可选件不算 —— 见 BODY_DEFINITION_FILES 的实测理由 return REQUIRED_BODY_DEFINITION_FILES.includes(rel); } return text.trim() === '' || text.includes(BASE_SKELETON_SENTINEL); }); } /** missingBodyDefinitionIn 的 agentId 版(真实仓库的 system/agents/{id}) */ function missingBodyDefinition(agentId) { return missingBodyDefinitionIn(bodyDir(agentId)); } /** * 寻址名 → `{ companyId, agentId }`。`"cid:aid"` 拆开,没有冒号则 companyId 为 null。 * * 按**第一个**冒号切(而不是 split(':') 取前两段):多余的冒号留给 agentId, * 至少不会把地址后半截默默吞掉 —— 吞掉的话寻址会落到一个存在但不对的目录上。 */ function parseAddress(addr) { const s = addr == null ? '' : String(addr); const i = s.indexOf(':'); if (i < 0) return { companyId: null, agentId: s }; return { companyId: s.slice(0, i) || null, agentId: s.slice(i + 1) }; } /** `{ companyId, agentId }` → 寻址名;没有公司时返回裸 agentId */ function formatAddress(companyId, agentId) { return companyId ? `${companyId}:${agentId}` : String(agentId); } /** * 这个 id 能不能拿来当公司 id(§5.1)。 * 命中本体自带的子目录名,公司态目录就会盖住 agent 自己的 memory / logs; * `_` / `.` 前缀是保留给 `_global` / `_base` / `.claude` 这类非公司项的。 */ function isReservedCompanyId(id) { if (!id) return false; const s = String(id); return s.startsWith('_') || s.startsWith('.') || RESERVED_SUBDIRS.has(s); } /** * 公司私有 agent 目录 `data/companies/{cid}/agents/{aid}` 的历史落点。 * * 批次 4 的 M2 已经把这 17 个目录搬空并删掉,寻址链路上**不再有任何一处会走到它**。 * 保留这一个函数是因为 agent-transcript 还要靠它反推 Claude CLI 的 projects 目录名 * (历史 transcript 是按当年的 cwd 落的盘,改不了),那处重写是批次 4-20 的事。 */ function legacyCompanyAgentDir(companyId, agentId) { return path.join(REPO_ROOT, 'data', 'companies', String(companyId), 'agents', String(agentId)); } /** * 长期状态(memory / conversations)的落点,按寻址名算 —— **写侧与读侧唯一的那一份实现**。 * * 归属是 `(agentId, companyId)`:同一个本体在两家公司各记各的,不共享(§一「非目标」第一条); * 没有公司的调用(master / agent-creator)落保留值 `_global`。 */ function companyStateDir(addr) { const { companyId, agentId } = parseAddress(addr); return companyDir(agentId, companyId); } /** * 执行路径(agent-manager)专用寻址:地址恒为 `cid:aid`,`aid` 恒指向本体。 * * 批次 4 之前这里还有一条「公司私有目录还在就按老布局走」的分支;M2 清空 `agents/` 之后 * 它恒不命中,连同 `systemAgentRef` 引用模式一起删掉了 —— 本体只有一份,不需要挑。 * * @returns {{ dir: string, agentsRoot: string, agentId: string, companyId: ?string }} * agentsRoot 是 dir 的父目录,供 scoped skill 遍历用(在哪找 agent,就在哪找它的 skill) */ function resolveAgentDir(addr) { const { companyId, agentId } = parseAddress(addr); return { dir: bodyDir(agentId), agentsRoot: AGENTS_ROOT, agentId, companyId }; } module.exports = { REPO_ROOT, AGENTS_ROOT, GLOBAL_COMPANY_ID, NO_TASK_ID, RESERVED_SUBDIRS, BODY_DEFINITION_FILES, REQUIRED_BODY_DEFINITION_FILES, OPTIONAL_BODY_DEFINITION_FILES, BASE_SKELETON_SENTINEL, missingBodyDefinition, missingBodyDefinitionIn, bodyDir, companyDir, taskDir, parseAddress, formatAddress, isReservedCompanyId, companyStateDir, legacyCompanyAgentDir, resolveAgentDir, };