agent-locator.js 12.3 KB
/**
 * 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 = '<!-- LEMONBOSS_BASE_SKELETON -->';

/**
 * 这份本体还缺哪些定义文件 —— 按**本体目录**判,不经过 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,
};