/** * CompanyService — 公司实例管理 * * 数据目录: data/{companyId}/ * 模板库: system/agents/(28 批次 1 起与全局 agent 本体目录合并) * * 核心职责: * 1. Company CRUD (create/get/list/shutdown) * 2. Agent 招聘 (hire: 模板优先 → base 元 agent 继承兜底) * 3. resolveAgentDir(companyId, agentId) 统一寻址 */ const fs = require('fs'); const path = require('path'); const { ensureSystemAgentRow, promoteDraftIfComplete, isUngraduatedStatus, orgGraphMembership, upsertOrgGraphNode } = require('./graph-service'); const { getDb } = require('./graph-db'); const locator = require('./agent-locator'); const { isReservedCompanyId, bodyDir, companyDir, missingBodyDefinition } = locator; const ROOT_DATA_DIR = path.resolve(__dirname, '../../data'); const DATA_DIR = path.join(ROOT_DATA_DIR, 'companies'); // 28 批次 1:`system/templates/` 已整体并入 `system/agents/`,本体只剩一个注册表。 // 「模板库」和「全局 agent 目录」从此是同一个目录,这里保留两个名字只是为了让 // 下面按语境读起来仍然通顺(模板库 = 拿来雇的本体集;全局 agent 目录 = 直接跑的本体集)。 // 批次 5-21:路径不再在这里自己 resolve 一遍 —— 四个 AgentScaffold 的 baseDir 加上 // 各处的本体根,全仓只认 agent-locator.AGENTS_ROOT 这一个定义(「同一个 AgentScaffold // 被三个不同 baseDir 实例化」正是 §2.1 碎片化的直接来源)。 const TEMPLATES_DIR = locator.AGENTS_ROOT; const GLOBAL_AGENTS_DIR = TEMPLATES_DIR; // 模板使用计数的**运行态**落点(27-P3B §四 W13)。 // // 以前 `_updateTemplateUsedCount()` 每次 hire 都原地 +1 改写 // `_registry.json` —— 那是一份**人工维护**的模板清单(name / // description / benchmarkScore / trainedAt 都是手写的,`template-refs.test.js` // 还拿它当「模板本体集的登记表」校验)。结果任何一次真跑都会在工作区留一条 // `M system/agents/_registry.json`,跟 agent 自写的 todo.md 一样是持续晃动的噪声, // 而边界守卫扫的就是整仓 git status。 // // 所以计数搬到 data/ 下的独立运行态文件(已 .gitignore),registry 里的 // `usedCount` 退化成**冻结的历史基线**,对外读回时基线 + 增量相加, // 前端 badge(CompanyDashboard.vue `tpl.usedCount`)看到的数字不回退。 const TEMPLATE_USAGE_PATH = path.join(ROOT_DATA_DIR, 'template-usage.json'); // 元 agent("标准基座员工 Base Agent")模板 id —— 自带 RESEARCH → PLAN → EXECUTE → VERIFY // 四段式生命周期。模板库里没有对应角色时,新 agent 一律从它继承骨架 // (docs/todo/system-design/27-p2-org-graph.md §4.1 第 7 步 / 27-e2e §八bis)。 // `_` 前缀是 28 §4.1 的**保留 id** 约定:它住在本体目录里但不是可调用 agent, // 各处目录扫描(seedAgents / listTemplates / …)都靠这个前缀把它排除掉。 const BASE_META_AGENT_ID = '_base'; /** * agent-creator 的产出契约(28 批次 5-23)—— 单点定义,`company.js` 的 * `POST /:companyId/hire` 是唯一调用方(另一个调用方 `POST /templates/:templateId/generate` * 随手工模板管理页一起于 2026-08-23 下线)。 * * 为什么这段文本要连**运行期布局**一起交代:agent-creator 是个 LLM,它写 agent.md 的办法 * 是照着仓库里已有的本体依样画葫芦,而那些老本体写的是旧布局的 `../../workspace/…` * ——旧布局 cwd = `data/companies/{cid}/agents/{role}/` 时它才成立。28 号把 cwd 换成 * `system/agents/{aid}/{cid}/{taskId}/`(深度 3)之后,同一串相对路径解析到 * `system/agents/{aid}/workspace/`:读上游读到空目录、写产物写进 agent 私有目录( * `indexNodeOutputs` 扫不到),**两头都不报错**。org-coder / org-planner / code-planner * 三份本体就是这么写出来的 —— 布局约定不写进派单,下一份新本体照样这么写。 * * 段头 `[写入边界 …]` 引 prompt-builder 的常量而不是照抄字面量:那段是 graph 节点链路 * **唯一**的交付物归宿,字面量漂了就等于教 agent 去找一个不存在的段(同 §4.4 的分叉判据)。 * * @param {{agentId:string, bodyPath:string, description?:string, intro?:string}} o * @returns {string} 派给 agent-creator 的完整任务描述 */ function buildAgentDefinitionBrief({ agentId, bodyPath, description, intro }) { const { GRAPH_WRITE_BOUNDARY_HEADER } = require('../src/core/prompt-builder'); return [ intro || `请你为 Agent "${agentId}" 编写完整的定义文件。`, // 绝对路径:agent-creator 自己的 cwd 是它的任务态目录,任何相对路径从那里数出去都落空。 `本体目录: \`${bodyPath}\``, `该 Agent 的描述/用途: ${description || '通用执行型 Agent'}`, '**以下三个文件缺一不可,缺任何一个这个 Agent 都无法上岗**(会被卡在 draft 状态):', ` 1. \`${bodyPath}/agent.md\`(职责与工作流)`, ` 2. \`${bodyPath}/soul.md\`(人格与判断准则)`, ` 3. \`${bodyPath}/.claude/skills/main_skill/SKILL.md\`(核心技能操作手册)`, '另可补 identity.md / tools.md。写完后用 ls 验证这三个文件都非空。务必使用 Write 工具写入,不要使用 Edit 工具。', '', '【写 agent.md 前必读 —— 运行期布局,写错了两头都不报错】', `- 这个 agent 跑起来时 cwd **不是**本体目录,而是 \`system/agents/${agentId}/{公司ID}/{任务ID}/\`(深度恒为 3,只放草稿和日志、会被清理)。`, // 深度是 path.relative 实算出来的,不照抄 28 §4.1 —— 那里的 `../../../` 把结尾斜杠数成了一级, // prompt-builder.buildRuntimeCoordinates 的注释已经记过这笔账。多写一级指到 system/agents/ 本身: // 目录存在、可列,就是没有 soul.md —— 又一个不报错的读不到。 '- 本体文件(soul.md / tools.md / config.json)在 cwd 的 `../../`;公司态(company-context.md / user.md)在 `../`;仓库根在 `../../../../../`(例:`node ../../../../../bin/lemon.js …`)。', '- **禁止写 `../../workspace/…` 这类旧布局路径**:它解析到 `system/agents/<本体>/workspace/`,是个空目录 —— 读不到上游,写进去也不会被引擎索引。', `- 上游产物与交付物的落点**不要在 agent.md 里写死相对路径**:引擎每次调用都会把绝对路径放进任务描述的「[上游输入 …]」「${GRAPH_WRITE_BOUNDARY_HEADER}」「[节点产物目录]」三段,让它去那里取。`, '- 提示词开头还有一段「运行时坐标(绝对路径,以此为准)」,与本文件冲突时以坐标声明为准。', ].join('\n'); } // ── 招聘的两层,与「谁算 org 节点」(27-P3B §四 W7 / 27-REQ R-18.2 末段)────── // // 招聘有两层,但只有 **org 层**的被雇者才是公司 org graph 上的一个节点: // org 层:orggraph-planning 按它规划的 org graph 雇进来的角色(org-planner / org-coder / // org-validator …),以及公司还没有图时人工从界面雇的员工 —— // P2 §4.2 的「hire 成功后写一行 org_graph_nodes」说的是它们; // work 层:workgraph-planning 每接到一个任务、按它规划出的 work graph 现雇的本体 // (prd-planning / benchmark-validator / validator-triager / coder-validator …)。 // 这些本体是某个 org node **内部**工作流的节点,写进 org_graph_nodes 不只是 // 把控制台左栏画歪:`work-graph-db.js:473` 起 org run 时读的就是这张表的 active 行, // 混进去的行会变成一个真的会被执行的 org 节点;而 POST /org-graph 又会把不在 // 规划 agent 节点列表里的行标 removed —— 两边互相打架。 // // **判据不问调用方,只看数据**。原因是唯一会雇 work 层本体的真实调用方是 // `POST /api/company/:companyId/hire`(workgraph-planning 在 CLI 里 curl 它), // 该路由按字段解构 body、不透传未知字段,所以「加个 opts.layer 参数」在本轮是一个 // 永远没人传的死开关。何况「谁雇的」本来也不该决定「它是不是组织节点」。三条判据: // // ① 本体自称是图基础设施(config.json 的 type ∈ NON_ORG_AGENT_TYPES)→ 永不入图。 // meta-bootstrap 那半条 P2 就有了(否则建公司到规划跑完之间 GET /org-graph 会返回 // 一张只有 orggraph-planning 的假组织图);W7 把它推广到 meta-planning,覆盖 // graph-engine.resolvePlanningAgentName() 按需雇的 workgraph-planning,以及 // workgraph-planning 自己雇的 prd/sdd/brd-planning、goal-benchmark。 // ② 本体在 system/work-graph-templates/*.json 里被点名当 work graph 节点 → 永不入图 // (planner-validator / validator-triager / benchmark-validator / testdata-validator, // 这些 type='validator' 的本体判据①盖不住,而 org-validator 只当 ownerOrgNode 出现、 // 不是 nodes[] 里的 systemAgentId,所以不会被误伤)。 // ③ 公司已经有一张**成型的**组织工作流图,而这次雇的角色不在图上 → 不入图: // 图一旦成型,作者就是 orggraph-planning(POST /org-graph),hire() 不再往上追加节点。 // 这条兜住 ①② 都不认识的、按 executor.json 的 {{implAgent}} / {{validatorAgent}} // 动态选出来的本体。 // // **「成型」的判据在 graph-service.orgGraphMembership(),不在这里** —— 改判据去那儿改, // 别照这段注释理解。它现在是两条: // · 主判据 `authored_by='planner'` 的节点存在(`writeGraph` 是唯一写它的地方) // —— 这是「orggraph-planning 提交过一张图」的**直接**证据; // · 回落判据 active 的 `org_graph_edges` 存在 —— 只为该列之前的存量行(authored_by 为 NULL)。 // // ⚠️ **回落判据曾经是唯一判据,那是个真的洞**(P3B W7):单 org node 的最小图零条边, // 三条判据全落空 → `readme-writer` 这类占位符现填的本体被真的写进 `org_graph_nodes`, // 控制台左栏并排显示两张 org 卡片。修在 `58a2865`,守卫 // `company-hire-org-layer.test.js` 的 `CO_ZEROEDGE` + `W7-fix` 两条。 // **别拿节点数当信号**:P1/P2 的 hire 自己就会造节点,那会把「同一家公司连雇两个人, // 第二个不入图」这条 P2 语义废掉(`graph-agent-p1-hire.test.js` 就是这么用的)—— // `authored_by` 能分开「planner 画的」和「hire 顺手记的」,节点数不能。 // // 顺带修掉一个老问题:org 节点 role_name='org-coder' 绑定本体 'coder' 时, // 在成型图上再雇一次 'coder' 以前会多出一行同义节点,现在不会了。 // // 默认行为(都不命中)= 写。P1/P2 既有语义「雇一个角色 → org graph 出现这个节点」 // 在「公司还没有成型的图」这个前提下原样保住(graph-agent-p1-acceptance.test.js 判据 1)。 const NON_ORG_AGENT_TYPES = new Set(['meta-bootstrap', 'meta-planning']); const WORK_GRAPH_TEMPLATES_DIR = path.resolve(__dirname, '../../system/work-graph-templates'); let workGraphBodyIds = null; // 懒加载 + 进程内缓存(骨架是随代码发布的静态文件) /** system/work-graph-templates/*.json 里 nodes[].systemAgentId 的全集(跳过 {{占位符}}) */ function workGraphTemplateBodies() { if (workGraphBodyIds) return workGraphBodyIds; workGraphBodyIds = new Set(); try { for (const f of fs.readdirSync(WORK_GRAPH_TEMPLATES_DIR)) { if (!f.endsWith('.json')) continue; const tpl = JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_TEMPLATES_DIR, f), 'utf-8')); for (const n of tpl.nodes || []) { const id = n && n.systemAgentId; if (id && !id.startsWith('{{')) workGraphBodyIds.add(id); } } } catch (err) { // 读不到就退化成「这条判据不生效」,另外两条还在,绝不因此挡住招聘 console.warn(`[Company] ⚠️ 读 work graph 骨架失败,W7 判据②本轮跳过: ${err.message}`); } return workGraphBodyIds; } /** * 这次招聘要不要在 org_graph_nodes 里记一行?判据见上方注释。 * * @param {string} companyId * @param {string} roleName * @param {object|null} agentConfig 被雇 agent 的 config.json(取 type) * @returns {{ record: boolean, reason: string }} reason 用于日志,说明是哪一条判据生效 */ function decideOrgNodeRecording(companyId, roleName, agentConfig) { const type = agentConfig && agentConfig.type; if (NON_ORG_AGENT_TYPES.has(type)) { return { record: false, reason: `type=${type},图基础设施本体从不作为 org 节点` }; } if (workGraphTemplateBodies().has(roleName)) { return { record: false, reason: 'work graph 骨架里点名的 work 层本体' }; } const { hasWorkflow, isNode } = orgGraphMembership(companyId, roleName); if (hasWorkflow && !isNode) { return { record: false, reason: '公司已有成型的 org graph,而该角色不在图上(work 层本体)' }; } return { record: true, reason: isNode ? '该角色就是图上的 org 节点' : '公司还没有成型的 org graph(P2 语义:雇进来就记一行)' }; } /** * 「这家公司雇了谁」的**唯一**真相来源(28 §4.2 / 批次 4-19)。 * * 为什么单独提成模块级函数而不是留在 CompanyService 上:读这份名册的有六处 * (company.js 的心跳/技能/消息/统计、task-service 的会话扫描、agent-chat 的 agent 列表、 * heartbeat-service 的启动装载、company-heartbeat-service 的日报、graph-engine 的按需雇佣), * 它们大多不需要、也不该为了问一句「有哪些人」去 new 一个 CompanyService * (构造它会连带把公司目录、模板库、work graph 骨架都拉起来)。 * * ⚠️ `company.json.agents[]` 从批次 4 起**只写不读**:hire()/fire() 仍然维护它, * 是为了让还没跟上的外部工具(迁移脚本、手工 cat company.json 的人、graph-seed 的补种入口) * 看到的东西不突然消失;但它是一份**没有主键约束、写失败也不会有人发现**的 JSON 数组, * 而 company_agents 有 `(company_id, role_name)` 主键和指向 system_agents 的外键。 * 两份名册不一致时以本表为准 —— 读取侧一律走这里,不要再回去读那个数组。 * * @param {string} [companyId] 不传 = 跨公司全量(启动时装心跳 / 列全系统 agent 用) * @returns {Array<{company_id:string, system_agent_id:string, role_name:string}>} */ function listCompanyAgents(companyId) { const where = companyId ? `WHERE status = 'active' AND company_id = ?` : `WHERE status = 'active'`; const stmt = getDb().prepare( `SELECT company_id, system_agent_id, role_name FROM company_agents ${where} ORDER BY company_id, hired_at` ); return companyId ? stmt.all(companyId) : stmt.all(); } // 模板骨架文件白名单 (复制这些,不复制 memory/workspace/conversations 等运行态数据) const TEMPLATE_FILES = [ 'agent.md', 'soul.md', 'identity.md', 'user.md', 'tools.md', 'heartbeat.md', 'plan.md', 'todo.md', 'cron.json', 'config.json', ]; class CompanyService { // ═══════════════════════════════════════ // Company CRUD // ═══════════════════════════════════════ /** * 创建一家新公司 * @param {object} opts - { id, name, goal, description } * goal company-goal:这家公司要达成什么(定性,OKR 树的输入,orggraph-planning 规划流水线的主依据) * description 这家公司是干什么的(背景) * 两者的区分见 docs/todo/system-design/27-naming-conventions.md §四 */ create({ id, name, goal = '', description = '', ownerId = null }) { // 公司 id 会成为 `system/agents/{aid}/{cid}/` 这一层的目录名,和本体自带的子目录同层 // (28 号设计 §5.1):一家叫 `memory` 的公司会直接盖住每个 agent 的记忆目录, // 而且是「目录在、代码不抛」的那种盖法。所以拦在建公司这一刻,不留到运行时。 if (isReservedCompanyId(id)) { throw new Error(`公司 id ${id} 不可用:命中 agent 本体的保留目录名(或以 _ / . 开头)`); } const companyRoot = path.join(DATA_DIR, id); if (fs.existsSync(companyRoot)) { throw new Error(`公司 ${id} 已存在`); } // 创建目录结构。**没有 agents/**(28 G3):公司不持有 Agent 实体, // 雇佣关系是 company_agents 的一行,agent 的公司态在 system/agents/{aid}/{cid}/。 const subdirs = ['okr', 'pipelines', 'workspace', 'workspace/assets', 'workspace/output', 'docs', 'logs/pipeline_runs', 'logs/audit']; for (const sub of subdirs) { fs.mkdirSync(path.join(companyRoot, sub), { recursive: true }); } const now = new Date().toISOString(); const company = { id, name, goal, description, status: 'creating', // creating → running → paused → shutdown createdAt: now, updatedAt: now, createdBy: 'master', // 38 号文档 Phase 2:谁能看到这家公司(users.id)。null = 存量公司未迁移 // (理论上不该出现——迁移脚本会把它们全部划给种子账号,见 scripts/backfill-company-owner.js)。 // 跟 createdBy 是两回事:createdBy 恒为字面量 'master'(谁的公司都一样), // ownerId 才是真实的多租户归属字段。 ownerId, ceo: null, sop: null, agents: [], heartbeat: { enabled: false, // CEO 上任后才开启 intervalMinutes: 30, }, config: { timezone: 'Asia/Shanghai', maxAgents: 20, }, }; this._saveCompany(company); // 重新保存(agents 列表已更新) this._saveCompany(company); console.log(`[Company] 🏢 创建公司: ${id} (${name})`); return company; } /** * 获取公司详情 */ get(companyId) { const fp = path.join(DATA_DIR, companyId, 'company.json'); if (!fs.existsSync(fp)) return null; return JSON.parse(fs.readFileSync(fp, 'utf-8')); } /** * 列出公司。 * @param {object} [opts] * @param {object} [opts.user] - 传了就按归属过滤(admin 不过滤,见 canAccessCompany); * 不传 = 不过滤,返回全部——心跳等系统内部巡检(company-heartbeat-service.js)要看 * 所有公司,不代表任何一个用户,不能被误判成"只属于某个用户"。 */ list({ user } = {}) { if (!fs.existsSync(DATA_DIR)) return []; return fs.readdirSync(DATA_DIR, { withFileTypes: true }) .filter(d => d.isDirectory() && fs.existsSync(path.join(DATA_DIR, d.name, 'company.json'))) .map(d => { const company = this.get(d.name); if (!company) return null; if (user && !canAccessCompany(company, user)) return null; return { id: company.id, name: company.name, status: company.status, // 人数查 company_agents,不数 company.json.agents[](后者只写不读,见 listCompanyAgents) agentCount: listCompanyAgents(company.id).length, ceo: company.ceo, updatedAt: company.updatedAt, }; }) .filter(Boolean); } /** * 更新公司信息 */ update(companyId, updates) { const company = this.get(companyId); if (!company) return null; if (updates.name !== undefined) company.name = updates.name; if (updates.goal !== undefined) company.goal = updates.goal; if (updates.description !== undefined) company.description = updates.description; if (updates.status !== undefined) { company.status = updates.status; // Track startedAt for uptime calculation if (updates.status === 'running' && !company.startedAt) { company.startedAt = new Date().toISOString(); } else if (updates.status === 'shutdown' || updates.status === 'paused') { company.startedAt = null; } } if (updates.sop !== undefined) company.sop = updates.sop; if (updates.heartbeat !== undefined) company.heartbeat = { ...company.heartbeat, ...updates.heartbeat }; // 42 号文档 Phase 3:target_dir 的配置入口。校验复用 path-resolver 的 validateLinkedTarget // ——39 号文档 P5 落地时就是从这里抽出来的共享函数,graph-engine.js 的 ensureProjectRoot // 用它做运行时校验,这里在配置阶段就先做一遍,不用等第一次真的分配项目根才发现路径不对。 // 校验失败直接抛出(调用方 try/catch 转 400),不静默吞掉、不写进一半的脏状态—— // 抛出发生在 _saveCompany() 之前,本次 update 里其它字段也不会被落盘(同一次保存,要么全写要么不写)。 if (updates.target_dir !== undefined) { if (updates.target_dir === null || updates.target_dir === '') { company.target_dir = null; company.target_mode = null; } else { // 缺省 'linked-existing':与 graph-engine.js 的 readCompanyTargetConfig 同一条默认值口径, // 两处判据分叉是这个仓库反复栽过的坑("两套体系不统一"),不能各写一份。 const mode = updates.target_mode === 'linked-new' ? 'linked-new' : 'linked-existing'; const { validateLinkedTarget } = require('./devloop/path-resolver'); company.target_dir = validateLinkedTarget(updates.target_dir, mode); company.target_mode = mode; } } company.updatedAt = new Date().toISOString(); this._saveCompany(company); return company; } /** * 关停公司 (软关停: 标记状态, 停止心跳, 保留数据) */ shutdown(companyId) { const company = this.get(companyId); if (!company) return null; company.status = 'shutdown'; company.heartbeat.enabled = false; company.startedAt = null; company.updatedAt = new Date().toISOString(); this._saveCompany(company); console.log(`[Company] 🛑 关停公司: ${companyId}`); return company; } /** * 删除公司 (仅删除 company.json 定义即可,暂时不清理目录数据) */ delete(companyId) { const company = this.get(companyId); if (!company) return null; const fp = path.join(DATA_DIR, companyId, 'company.json'); if (fs.existsSync(fp)) { // 通过重命名或者直接删除 company.json 来让 list 无法读取 fs.renameSync(fp, path.join(DATA_DIR, companyId, 'company.deleted.json')); } console.log(`[Company] 🗑️ 删除公司: ${companyId}`); return company; } // ═══════════════════════════════════════ // Agent 招聘系统 (Hiring Pipeline) // ═══════════════════════════════════════ /** * 招聘一个 Agent 到公司(28 批次 4-18:公司不再持有 Agent 实体) * * 流程: 查本体 system/agents/{roleName} → 写 company_agents 一行 → 初始化公司态目录 * 本体不存在 → 从 base 元 agent 继承骨架 (hireFromBaseMetaAgent), * 仍返回 { needsTraining: true },由上层调用 agent-creator + innovator 填充定义 * * 与 P1/P2 的区别:**一个字节都不再拷进 `data/companies/{cid}/agents/`**。 * 那个目录整体消失了(G3),雇佣关系退化成 company_agents 的一行 + 一个公司态目录 * `system/agents/{aid}/{cid}/`(company-context.md + user.md + 空 memory)。 * `config.json` 随之不再有公司私有副本,`systemAgentRef` 字段一并废弃 —— * 本体只有一份,agent-manager 不需要「指哪一份」的指针。 * * @param {string} companyId * @param {string} roleName - 角色名 (如 "ceo", "content-writer") * @param {object} opts - { customAgent, customSoul, ... } 可选的上下文覆盖 * @returns {object} { status, agentId, agentDir, needsTraining? } agentDir = 公司态目录 */ hire(companyId, roleName, opts = {}) { const company = this.get(companyId); if (!company) throw new Error(`公司 ${companyId} 不存在`); // 幂等只看 company_agents:目录判重已经不成立 —— 公司态目录会被 agent-manager // 在**执行**时 mkdirSync 出来(cwd 不存在 CLI 起不来),拿它当「雇过了」的信号 // 会把「跑过一次 = 雇过了」混为一谈。 const hired = this._getCompanyAgent(companyId, roleName); if (hired) { return { status: 'already_exists', agentId: hired.system_agent_id, agentDir: companyDir(hired.system_agent_id, companyId) }; } // 阶段 A: 本体注册表里找 const bodyPath = bodyDir(roleName); if (fs.existsSync(bodyPath)) { // 本体在磁盘上,库里的行说了算「能不能上岗」:批次 5 起 create-agent 生成的本体 // 会先停在 status='draft'(定义没填满不许被雇)。库里还没有这行是**正常**的 // ——真跑里 work 层本体常常是先被雇、再由 _recordHireInGraph 补登记的。 // // 问之前先现场校验一次(批次 5-23):agent-creator 是 fire-and-forget 的, // 它填完文件那一刻没有回调点可挂,所以「转正」只能由下一个来问的人触发。 const gate = promoteDraftIfComplete(roleName); // 判据是「是不是 draft/training」而不是「是不是 active」:status 不是封闭取值域, // 生产库里 36 行合法本体是 enabled / idle / ready / trained(seedAgents 把 // config.json.status 原样 upsert 进来的)。用 `!== 'active'` 会把 workgraph-planning / // master / agent-creator / qa-judge 一起判成「定义没填满」,missing 还是空数组。 // 取值域的唯一持有者是 graph-service(见 isUngraduatedStatus 的注释)。 if (isUngraduatedStatus(gate.status)) { console.log(`[Company] ⏸️ ${companyId} 暂不能雇 ${roleName}:本体 status=${gate.status},缺 ${gate.missing.join(' / ') || '(未知)'}`); return { status: 'body_not_ready', agentId: roleName, agentDir: companyDir(roleName, companyId), needsTraining: true, missing: gate.missing, message: `本体 "${roleName}" 的定义还没填满(status=${gate.status},缺 ${gate.missing.join(' / ') || '未知文件'}),不允许上岗。`, }; } const agentConfig = this._readAgentConfig(bodyPath); // Org Graph 引用节点记录(审计用途,失败不影响雇佣结果本身)。判据见 NON_ORG_AGENT_TYPES 上方注释。 this._recordHireInGraph(companyId, roleName, agentConfig); this._recordCompanyAgent(companyId, roleName); const stateDir = this._initCompanyState(companyId, roleName, opts); this._updateTemplateUsedCount(roleName); // company.json.agents[] 继续维护,但它**只写不读**了(理由见 listCompanyAgents 的注释)。 if (!company.agents.includes(roleName)) { company.agents.push(roleName); } if (agentConfig && agentConfig.type === 'cxo') { company.ceo = roleName; } company.updatedAt = new Date().toISOString(); this._saveCompany(company); console.log(`[Company] ✅ ${companyId} 招聘 ${roleName}(引用本体 system/agents/${roleName})`); return { status: 'hired_from_template', agentId: roleName, agentDir: stateDir }; } // 阶段 B: 本体不存在 → 先从 base 元 agent 继承出骨架,再交给 agent-creator 填充角色定义 const inherited = this.hireFromBaseMetaAgent(companyId, roleName, opts); if (inherited) return inherited; // 兜底: 连 base 元 agent 都不存在(不应发生)→ 退回原有的纯 needs_training 行为 console.log(`[Company] ⚠️ 本体注册表里不存在 "${roleName}",且 base 元 agent 缺失,需要 agent-creator + innovator 培养`); return { status: 'needs_training', agentId: roleName, agentDir: companyDir(roleName, companyId), needsTraining: true, }; } /** * 从 base 元 agent 继承出一个新角色的骨架(27-p2-org-graph.md §4.1 第 7 步) * * 本体注册表里没有这个角色时的招聘路径:不再直接返回 needs_training 让上层从零培养, * 而是先把 `system/agents/_base/` 的骨架文件拷成一份**新本体** `system/agents/{roleName}/` * —— 新 agent 因此自动带上 base 的 RESEARCH → PLAN → EXECUTE → VERIFY 四段式生命周期; * 角色特有的定义仍由上层触发 agent-creator 填充(所以返回值保留 needsTraining: true)。 * * 28 批次 4 起骨架落点从 `data/companies/{cid}/agents/{roleName}/` 改成本体目录: * 公司私有目录整体消失了,继续往那儿写就是刚清完又造一个新的死角 * (§2.4 的 org-coder / org-planner 正是这么变成「永远无法被第二家公司复用」的)。 * * 批次 5-22 补上闸门那一段,完整流程: * `_base` → `system/agents/{roleName}`(复制骨架) * → system_agents 插一行 origin='created', **status='draft'** * → 上层触发 agent-creator 填 agent.md / soul.md / SKILL.md(fire-and-forget) * → 校验齐全 → status='active'(按需,见 promoteDraftIfComplete) * → 写 company_agents 一行 * * ⚠️ 这里的 `status='draft'` 必须**赶在** `_recordHireInGraph` / `_recordCompanyAgent` 前面插: * 那两条路径内部都会 `ensureSystemAgentRow()` 兜底补行,而它默认插 'active' —— * 让它先插上,闸门就等于从来没装过(行已存在,后插的一律不改字段)。 * * 闸门挡的是**扩散**,不是本次招聘(§九「污染面从 1 家扩到全系统」):发起方这家公司 * 照常拿到 company_agents 一行 + needsTraining:true,别家公司再 hire 同一个 id 会被 * hire() 阶段 A 的 body_not_ready 挡住,直到定义填满。若不给发起方记这一行, * 这次招聘就没有任何痕迹,也没人会再来问一次、把它转正。 * * @param {string} companyId * @param {string} roleName * @param {object} opts - 与 hire() 同:{ type, name, description, customAgent, customSoul, customUser } * @returns {object|null} { status:'hired_from_base', agentId, agentDir, needsTraining }; * base 模板缺失时返回 null,由调用方决定兜底行为 */ hireFromBaseMetaAgent(companyId, roleName, opts = {}) { const company = this.get(companyId); if (!company) throw new Error(`公司 ${companyId} 不存在`); const baseDir = path.join(TEMPLATES_DIR, BASE_META_AGENT_ID); if (!fs.existsSync(baseDir)) return null; const hired = this._getCompanyAgent(companyId, roleName); if (hired) { return { status: 'already_exists', agentId: roleName, agentDir: companyDir(roleName, companyId) }; } // 1. 拷贝 base 骨架成一份新本体(TEMPLATE_FILES 白名单 + .claude/ + skills/) const newBodyDir = bodyDir(roleName); this._copySkeleton(baseDir, newBodyDir); // 2. config.json: 标记为待培养 + 记录继承来源 const configPath = path.join(newBodyDir, 'config.json'); const config = this._readAgentConfig(newBodyDir) || {}; config.status = 'training'; config.inheritedFrom = BASE_META_AGENT_ID; if (opts.type) config.type = opts.type; if (opts.name) config.name = opts.name; if (opts.description) config.description = opts.description; fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); // 3. 本体注册表:origin='created' + status='draft'(见函数头的「必须赶在前面」) ensureSystemAgentRow(companyId, roleName, roleName, { status: 'draft' }); // 骨架拷完就校验一次:`_base` 眼下只带 agent.md,所以正常是「缺 soul.md + SKILL.md」 // 停在 draft;哪天 `_base` 补齐了,新本体一出生就是 active,不必再等下一个人来问。 const gate = promoteDraftIfComplete(roleName); // 4. Org Graph 节点 + scope 治理(失败不影响雇佣结果本身)。 // 这条路径下 config.json 刚由本函数写出(type 来自 opts.type),直接把它喂给判据。 this._recordHireInGraph(companyId, roleName, config); try { this._ensureCompanyScopedAgent(companyId, roleName); } catch (err) { console.warn(`[Company] ⚠️ system_agents scope 校正失败(不影响雇佣结果): ${err.message}`); } // 5. 雇佣关系 + 公司态目录 + 公司名册 this._recordCompanyAgent(companyId, roleName); const stateDir = this._initCompanyState(companyId, roleName, opts); if (!company.agents.includes(roleName)) { company.agents.push(roleName); } company.updatedAt = new Date().toISOString(); this._saveCompany(company); const ready = gate.status === 'active'; console.log(`[Company] 🧬 ${companyId} 招聘 ${roleName}(从 base 元 agent 继承出本体,status=${gate.status}${ready ? '' : `,缺 ${gate.missing.join(' / ')}`})`); return { status: 'hired_from_base', agentId: roleName, agentDir: stateDir, needsTraining: !ready, missing: gate.missing, message: ready ? `Agent "${roleName}" 已从 base 元 agent 继承出完整本体,可直接上岗。` : `Agent "${roleName}" 已从 base 元 agent 继承骨架(含四段式生命周期),本体 status: draft,还缺 ${gate.missing.join(' / ')}。等待 agent-creator 补充后自动转正。`, }; } /** * 这家公司雇没雇过这个角色(28 §4.2 的 company_agents 一行)。 * @returns {{company_id:string, system_agent_id:string, role_name:string}|undefined} */ _getCompanyAgent(companyId, roleName) { return getDb() .prepare(`SELECT * FROM company_agents WHERE company_id = ? AND role_name = ? AND status = 'active'`) .get(companyId, roleName); } /** * 落一行雇佣关系。幂等(主键 (company_id, role_name) + INSERT OR IGNORE)。 * * 自己先补 system_agents 那一行:`system_agent_id` 是指向它的外键,缺行就直接抛。 * `_recordHireInGraph` 通常已经补过了,但那一整段是「失败只告警」的审计动作 —— * 靠它的副作用来满足本函数的前置条件,等于让雇佣的成败取决于一个被吞掉的异常。 */ _recordCompanyAgent(companyId, roleName, systemAgentId = roleName) { ensureSystemAgentRow(companyId, systemAgentId, roleName); getDb().prepare(` INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) VALUES (?, ?, ?, 'active', ?) `).run(companyId, systemAgentId, roleName, new Date().toISOString()); } /** * 把一次成功的招聘落到图上(hire() 与 hireFromBaseMetaAgent() 共用的收尾动作)。 * * org 层 → 记一行 org_graph_nodes(顺带补 system_agents,upsertOrgGraphNode 内部做)。 * work 层 → **不记** org_graph_nodes,但**仍然补** system_agents 那一行: * work_graph_nodes.system_agent_id 是指向 system_agents 的外键, * 少了这行,workgraph-planning 雇完人再提交 work graph 会直接外键挂 * (27-P3B §四 W1 记录过同一条失效链)。 * * 图写失败一律降级成告警,不影响雇佣结果本身(保持 P1/P2 既有行为)。 */ _recordHireInGraph(companyId, roleName, agentConfig) { const { record, reason } = decideOrgNodeRecording(companyId, roleName, agentConfig); try { if (record) { upsertOrgGraphNode(companyId, roleName, roleName); } else { ensureSystemAgentRow(companyId, roleName, roleName); console.log(`[Company] ⏭️ ${roleName} 不写入 org_graph_nodes(${reason}),仅登记 system_agents`); } } catch (err) { console.warn(`[Company] ⚠️ 图登记失败(不影响雇佣结果): ${err.message}`); } } /** * 校正新建 agent 在 system_agents 里的归属:scope='company' + owner_company_id=本公司 * (27-graph-agent-architecture.md §3.2)。 * * graph-service.upsertOrgGraphNode() 的 fallback 插入已经是 scope='company',但它只在 * system_agents 里没有这行时才插;这里显式再兜一次,保证「公司自建 agent 一律 company 域」 * 这条不变量不依赖上游实现细节。 * 限定 origin='created' —— 绝不把 builtin 本体降级成公司私有。 */ _ensureCompanyScopedAgent(companyId, agentId) { getDb().prepare(` UPDATE system_agents SET scope = 'company', owner_company_id = @company_id, updated_at = @updated_at WHERE id = @id AND origin = 'created' AND (scope <> 'company' OR owner_company_id IS NOT @company_id) `).run({ id: agentId, company_id: companyId, updated_at: new Date().toISOString() }); } /** * 用 AgentScaffold 直接创建 Agent 骨架 * (Phase 2: 不走 HTTP,直接调用脚手架) * * 只在 `_base` 元 agent 本身缺失、hire() 退回 needs_training 时才会走到(见 hireWithTraining)。 * 28 批次 4:骨架落点跟着 hireFromBaseMetaAgent 一起搬到本体目录 —— 公司私有目录已经 * 删干净了,这里再往那儿写就是把它建回来。批次 5-21 起 baseDir 直接取 locator.AGENTS_ROOT, * 与另外三个 AgentScaffold 实例同源。 * * 产出与 hireFromBaseMetaAgent 同样过批次 5-23 的闸门:脚手架写得出 agent.md / soul.md, * 但 `.claude/skills/main_skill/SKILL.md` 是空占位(bootstrap.skillMd 只有 meta_creator 会给), * 所以这条路径造出来的本体正常停在 draft,别家公司雇不走。 * * @param {string} companyId * @param {string} roleName * @param {object} opts - { type, name, description, parentId } * @returns {Promise} { agentDir, agentId } */ async scaffoldNewAgent(companyId, roleName, opts = {}) { const company = this.get(companyId); if (!company) throw new Error(`公司 ${companyId} 不存在`); // 使用 ESM 导入 AgentScaffold const { AgentScaffold } = await import('../../packages/agent-core/AgentScaffold.js'); const scaffold = new AgentScaffold(TEMPLATES_DIR); const agentDir = await scaffold.create({ id: roleName, type: opts.type || 'skill', name: opts.name || roleName, parentId: opts.parentId || company.ceo || 'ceo', goalId: opts.goalId || null, }); // 本体注册表先落 draft 行,再让下游那两个 ensureSystemAgentRow 的兜底看到「已有行」 // ——顺序颠倒的话它们会先插一行 active,闸门等于没装(理由同 hireFromBaseMetaAgent) ensureSystemAgentRow(companyId, roleName, roleName, { status: 'draft' }); const gate = promoteDraftIfComplete(roleName); // 公司态目录(company-context.md / user.md / 空 memory) this._initCompanyState(companyId, roleName, opts); this._recordHireInGraph(companyId, roleName, { type: opts.type }); this._recordCompanyAgent(companyId, roleName); // 标记为 training 状态 const configPath = path.join(agentDir, 'config.json'); if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); config.status = 'training'; fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); } // 更新公司 if (!company.agents.includes(roleName)) { company.agents.push(roleName); } company.updatedAt = new Date().toISOString(); this._saveCompany(company); console.log(`[Company] 🎓 ${companyId} 创建新 Agent 骨架: ${roleName} (training, 本体 status=${gate.status})`); return { agentDir, agentId: roleName, needsTraining: gate.status !== 'active', missing: gate.missing }; } /** * 将训练合格的 Agent 沉淀为模板 * (Phase 2: Benchmark ≥ 95 后调用) * * 28 批次 4 起「公司里的 agent」和「模板」是同一份本体 `system/agents/{id}/`, * 所以这里退化成**给本体盖章**:写 config.json 的 benchmarkScore + 更新 `_registry.json`, * 不再有跨目录的拷贝。⚠️ 原来那句「模板已存在就 rmSync 覆盖」在合并后会**删掉本体自己**, * 必须先删掉它 —— 这是本次合并里唯一一处会造成不可恢复数据丢失的旧逻辑。 * * @param {string} companyId * @param {string} agentId * @param {number} benchmarkScore * @returns {object} { status, templateId } */ depositTemplate(companyId, agentId, benchmarkScore) { if (benchmarkScore < 95) { return { status: 'rejected', message: `评分 ${benchmarkScore} 未达标 (需 ≥ 95)`, agentId, }; } const agentDir = bodyDir(agentId); if (!fs.existsSync(agentDir)) { throw new Error(`Agent ${agentId} 在 ${companyId} 中不存在`); } const templateDir = agentDir; // 盖章写进本体的 config.json(公司特定信息本来就不在本体里,无需再剥) const stampPath = path.join(templateDir, 'config.json'); if (fs.existsSync(stampPath)) { const config = JSON.parse(fs.readFileSync(stampPath, 'utf-8')); delete config.companyId; config.benchmarkScore = benchmarkScore; config.trainedAt = new Date().toISOString().slice(0, 10); fs.writeFileSync(stampPath, JSON.stringify(config, null, 2), 'utf-8'); } // 更新模板注册表 const registryPath = path.join(TEMPLATES_DIR, '_registry.json'); let registry = []; if (fs.existsSync(registryPath)) { registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); } // 移除旧条目 (如果存在) registry = registry.filter(t => t.id !== agentId); const agentConfig = this._readAgentConfig(templateDir); registry.push({ id: agentId, name: agentConfig?.name || agentId, type: agentConfig?.type || 'skill', description: agentConfig?.description || '', benchmarkScore, trainedAt: new Date().toISOString().slice(0, 10), usedCount: 0, }); fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8'); // 将 Agent 状态从 training → active const configPath = path.join(agentDir, 'config.json'); if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); config.status = 'active'; fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); } console.log(`[Company] 📦 Agent ${agentId} 沉淀为模板 (benchmark: ${benchmarkScore})`); return { status: 'deposited', templateId: agentId, benchmarkScore }; } /** * 完整招聘流程 (Phase 2: 模板优先 → 骨架创建兜底) * 如果模板不存在,会创建骨架并返回 training 状态 * 上层(路由)负责异步触发 agent-creator + innovator 实际训练 * * @param {string} companyId * @param {string} roleName * @param {object} opts - { type, name, description, customAgent, customSoul, customUser } * @returns {Promise} */ async hireWithTraining(companyId, roleName, opts = {}) { // 先走标准招聘流程 const result = this.hire(companyId, roleName, opts); // P2 起模板缺失会走 hire() 内的 base 元 agent 继承分支(status='hired_from_base', // 已建好目录 + config.status='training' + needsTraining=true),无需再跑 scaffoldNewAgent。 // 只有 base 元 agent 本身缺失时 hire() 才会退回 needs_training,走下面的脚手架兜底。 if (result.status !== 'needs_training') { return result; } // 模板不存在 → 创建骨架 (Phase 2) const scaffold = await this.scaffoldNewAgent(companyId, roleName, { type: opts.type || 'skill', name: opts.name || roleName, description: opts.description || '', parentId: opts.parentId, customAgent: opts.customAgent, customSoul: opts.customSoul, customUser: opts.customUser, }); return { status: 'training', agentId: roleName, agentDir: scaffold.agentDir, needsTraining: scaffold.needsTraining !== false, missing: scaffold.missing || [], message: `Agent "${roleName}" 骨架已创建,状态: training。需要 agent-creator 定义 + innovator 训练至 ≥95 后才可上岗。`, }; } /** * 解雇 (suspend) 公司内的 Agent * * 28 批次 4:解雇 = 把 company_agents 那一行标成 removed,**不动本体** * (改本体的 config.status='suspended' 会让别家公司也雇不动他)。 * 公司态目录 `system/agents/{aid}/{cid}/` 原地保留 —— 记忆不随解雇删除(§5.6)。 */ fire(companyId, agentId) { const company = this.get(companyId); if (!company) return null; const hired = this._getCompanyAgent(companyId, agentId); if (!hired) return null; getDb().prepare( `UPDATE company_agents SET status = 'removed' WHERE company_id = ? AND role_name = ?` ).run(companyId, agentId); // 从公司 agents 列表移除 company.agents = company.agents.filter(a => a !== agentId); company.updatedAt = new Date().toISOString(); this._saveCompany(company); console.log(`[Company] 🚪 ${companyId} 解雇 ${agentId}`); return { status: 'suspended', agentId }; } /** * 列出公司内的所有 Agent (含状态) * * 名册来自 company_agents(28 §4.2),元数据从本体的 config.json 读回。 * 原来是扫 `data/companies/{cid}/agents/` 的子目录 —— 那个目录已经不存在了。 */ listAgents(companyId) { return listCompanyAgents(companyId).map((r) => { const config = this._readAgentConfig(bodyDir(r.system_agent_id)); return { id: r.role_name, // 本体 id 与角色名可以不同(同一个本体在一家公司里担两个角色),调用方要拼 // 本体目录时必须用这个,不能拿 id 去 bodyDir —— 拼出来的会是一个不存在的路径。 bodyId: r.system_agent_id, name: config?.name || r.role_name, type: config?.type || 'unknown', status: config?.status || 'unknown', companyId, }; }); } // ═══════════════════════════════════════ // 模板库管理 // ═══════════════════════════════════════ /** * 列出所有可用模板 */ listTemplates() { // 始终从目录扫描,合并 _registry.json 中的元数据(benchmarkScore, usedCount 等) if (!fs.existsSync(TEMPLATES_DIR)) return []; const registryPath = path.join(TEMPLATES_DIR, '_registry.json'); let registryMap = {}; if (fs.existsSync(registryPath)) { try { const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); for (const entry of registry) registryMap[entry.id] = entry; } catch {} } // usedCount 的读回环:registry 里是冻结的人工基线,data/template-usage.json 里是 // 每次 hire 累加的增量,两边相加才是对外的真实次数(W13)。 const usageMap = this._readTemplateUsage(); const templates = fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }) .filter(d => d.isDirectory() && !d.name.startsWith('_')) .map(d => { const templateDir = path.join(TEMPLATES_DIR, d.name); const config = this._readAgentConfig(templateDir); const regEntry = registryMap[d.name] || {}; let evolvingVersionCount = 0; let benchmarkVersionCount = 0; const evolvingDir = path.join(templateDir, 'memory', 'evolving'); const benchmarkDir = path.join(templateDir, 'memory', 'benchmark'); if (fs.existsSync(evolvingDir)) { evolvingVersionCount = fs.readdirSync(evolvingDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('v')).length; } if (fs.existsSync(benchmarkDir)) { benchmarkVersionCount = fs.readdirSync(benchmarkDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('v')).length; } // Get directory modification time for sorting let updatedAt = null; try { const configStat = fs.statSync(path.join(templateDir, 'config.json')); updatedAt = configStat.mtime.toISOString(); } catch { try { const dirStat = fs.statSync(templateDir); updatedAt = dirStat.mtime.toISOString(); } catch {} } return { id: d.name, name: regEntry.name || config?.name || d.name, type: regEntry.type || config?.type || 'unknown', description: regEntry.description || config?.description || '', benchmarkScore: regEntry.benchmarkScore || config?.benchmarkScore || null, trainedAt: regEntry.trainedAt || config?.trainedAt || null, usedCount: (regEntry.usedCount || 0) + (usageMap[d.name] || 0), fullPath: templateDir, evolvingVersionCount, benchmarkVersionCount, updatedAt, }; }); return templates; } /** * 重建模板注册表 */ rebuildTemplateRegistry() { if (!fs.existsSync(TEMPLATES_DIR)) { fs.mkdirSync(TEMPLATES_DIR, { recursive: true }); return []; } const registry = fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }) .filter(d => d.isDirectory() && !d.name.startsWith('_')) .map(d => { const templateDir = path.join(TEMPLATES_DIR, d.name); const config = this._readAgentConfig(templateDir); let evolvingVersionCount = 0; let benchmarkVersionCount = 0; const evolvingDir = path.join(templateDir, 'memory', 'evolving'); const benchmarkDir = path.join(templateDir, 'memory', 'benchmark'); if (fs.existsSync(evolvingDir)) { evolvingVersionCount = fs.readdirSync(evolvingDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('v')).length; } if (fs.existsSync(benchmarkDir)) { benchmarkVersionCount = fs.readdirSync(benchmarkDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('v')).length; } return { id: d.name, name: config?.name || d.name, type: config?.type || 'unknown', description: config?.description || '', benchmarkScore: config?.benchmarkScore || null, trainedAt: config?.trainedAt || null, usedCount: config?.usedCount || 0, fullPath: templateDir, evolvingVersionCount, benchmarkVersionCount }; }); fs.writeFileSync( path.join(TEMPLATES_DIR, '_registry.json'), JSON.stringify(registry, null, 2), 'utf-8' ); return registry; } // ═══════════════════════════════════════ // Agent 统一寻址 // ═══════════════════════════════════════ /** * 统一定位 Agent 目录 * * 28 批次 4:本体只有一份,`companyId` 不再影响结果 —— 公司私有 agent 目录已消失。 * 参数保留是为了不动调用方签名(真正的分公司寻址走 agent-locator.companyDir)。 * * @param {string|null} companyId - 已忽略 * @param {string} agentId * @returns {string|null} 绝对路径, 不存在则返回 null */ static resolveAgentDir(companyId, agentId) { const dir = bodyDir(agentId); return fs.existsSync(dir) ? dir : null; } // ═══════════════════════════════════════ // 私有方法 // ═══════════════════════════════════════ _saveCompany(company) { const dir = path.join(DATA_DIR, company.id); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify(company, null, 2), 'utf-8'); } _readAgentConfig(agentDir) { const fp = path.join(agentDir, 'config.json'); if (!fs.existsSync(fp)) return null; try { return JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch { return null; } } /** * 复制一份本体骨架(只复制 md/json 骨架文件 + .claude/ + skills/,不复制运行态数据)。 * * 唯一调用方是 hireFromBaseMetaAgent(`_base` → 新本体)。P1/P2 的 * `_copyTemplate` / `_provisionReferenceAgent` 两条「把模板拷进公司私有目录」的分支 * 随 G3 一起删了 —— 雇佣不再产生任何本体副本。 * * **不建 memory/**:记忆归属 (agentId, companyId),落 `system/agents/{aid}/{cid}/memory/`, * 由 _initCompanyState 初始化。在本体根上再建一个空 memory/,就是给读侧留一个 * 「永远是空的、但确实存在」的假记忆目录。 */ _copySkeleton(srcDir, destDir) { fs.mkdirSync(destDir, { recursive: true }); for (const file of TEMPLATE_FILES) { const srcFile = path.join(srcDir, file); if (fs.existsSync(srcFile)) { fs.copyFileSync(srcFile, path.join(destDir, file)); } } // 复制 .claude/ 目录 (包含 skills/main_skill/SKILL.md 等) const srcClaude = path.join(srcDir, '.claude'); if (fs.existsSync(srcClaude)) { this._copyDirRecursive(srcClaude, path.join(destDir, '.claude')); } // 复制 skills/ 目录 (旧结构兼容) const srcSkills = path.join(srcDir, 'skills'); if (fs.existsSync(srcSkills)) { this._copyDirRecursive(srcSkills, path.join(destDir, 'skills')); } } _copyDirRecursive(src, dest) { fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { this._copyDirRecursive(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } } /** * 初始化 Agent 在这家公司的**公司态目录** `system/agents/{aid}/{cid}/`(28 §4.1)。 * * 三份文件,与 P1 的 `_injectCompanyContext` 一一对应,措辞原样保留 * (公司归属 / 汇报关系那两段是有意设计的,agent 读到的就是这几句): * company-context.md 公司归属语境(P1 引用模式产物,prompt-builder 直接拼进提示词) * user.md 汇报关系(本来就是整份重写的私有文件) * memory/memory.md 空记忆,归属 (agentId, companyId),不跨公司共享 * * 与 P1 的两处差别,都是「本体只有一份」逼出来的: * - **不写 config.json**:公司私有副本没有了,而本体的那份是全公司共享的只读文件, * 往里塞 companyId / parent_id 就是让 A 公司的汇报关系泄进 B 公司。 * `systemAgentRef` 一并废弃(本体只有一份,不需要指针)。 * - `opts.customSoul` 落进 company-context.md 的「公司文化」段,不再追加到 * `soul.md` —— 那是共享本体,一家公司的文化不该改写所有公司看到的灵魂。 * * @returns {string} 公司态目录绝对路径 */ _initCompanyState(companyId, agentId, opts = {}) { const stateDir = companyDir(agentId, companyId); const company = this.get(companyId); if (!company) return stateDir; fs.mkdirSync(stateDir, { recursive: true }); // 1. company-context.md:公司归属语境 const companyContext = [ '## 所属公司', `- **公司ID**: ${companyId}`, `- **公司名称**: ${company.name}`, `- **公司使命**: ${company.description || '待定义'}`, '', `> 你是 **${company.name}** 的员工。你的所有工作都应围绕公司的使命和目标展开。`, opts.customAgent || '', ]; if (opts.customSoul) { companyContext.push('', '## 公司文化', opts.customSoul, ''); } fs.writeFileSync(path.join(stateDir, 'company-context.md'), companyContext.join('\n'), 'utf-8'); // 2. user.md:汇报关系 const userContent = [ '# 与上级的关系', '', '## 所属公司', `- **公司**: ${company.name} (${companyId})`, '', '## 汇报对象', ]; // type 取自**本体**的 config.json(公司私有副本已不存在) const agentConfig = this._readAgentConfig(bodyDir(agentId)); if (agentConfig && agentConfig.type === 'cxo') { userContent.push('- **直接汇报**: Master (公司创始人/董事会)'); userContent.push('- **管理范围**: 公司内所有 Agent'); } else { userContent.push(`- **直接汇报**: ${company.ceo || 'CEO'} (公司 CEO)`); userContent.push('- **协同**: 公司内其他同事'); } userContent.push('', '## 沟通规则'); userContent.push('1. 完成任务后主动向上级汇报结果'); userContent.push('2. 遇到阻碍时及时上报,附带解决建议'); userContent.push('3. 保持透明,坏消息更要及时同步'); userContent.push(opts.customUser || ''); fs.writeFileSync(path.join(stateDir, 'user.md'), userContent.join('\n'), 'utf-8'); // 3. 空记忆。已存在就不动 —— 重新雇一个被 fire 过的角色不该抹掉他的记忆。 const memoryDir = path.join(stateDir, 'memory'); fs.mkdirSync(memoryDir, { recursive: true }); const memoryPath = path.join(memoryDir, 'memory.md'); if (!fs.existsSync(memoryPath)) { fs.writeFileSync(memoryPath, '# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n## 已验证的模式\n\n## 失败教训\n', 'utf-8'); } return stateDir; } /** * 读回模板使用增量计数(见 TEMPLATE_USAGE_PATH 注释)。 * 文件不存在 / 内容坏掉 → 返回空对象,调用方退化成只用 registry 基线。 * @returns {Record} */ _readTemplateUsage() { try { const raw = JSON.parse(fs.readFileSync(TEMPLATE_USAGE_PATH, 'utf-8')); return (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw : {}; } catch { return {}; } } /** * 更新模板使用次数 —— 只写运行态计数文件,**不再碰** `_registry.json`。 * 唯一调用方:hire() 走模板命中分支时(本文件内)。 */ _updateTemplateUsedCount(templateId) { try { const usage = this._readTemplateUsage(); usage[templateId] = (usage[templateId] || 0) + 1; fs.mkdirSync(path.dirname(TEMPLATE_USAGE_PATH), { recursive: true }); fs.writeFileSync(TEMPLATE_USAGE_PATH, JSON.stringify(usage, null, 2) + '\n', 'utf-8'); } catch (err) { console.warn(`[Company] ⚠️ 更新模板使用次数失败: ${err.message}`); } } } /** * 38 号文档 Phase 2:这个用户能不能碰这家公司——公司归属判据的**唯一持有者**。 * company.js 的 router.param('companyId', ...)、graph-run.js 的两个挂载点、 * devloop.js 反查 company_id 之后,都要调同一份,不能各写一份自己的判断 * (§六风险 1:判据分叉是这次改造最大的风险)。 * * admin 不过滤(现状唯一的 admin 就是种子账号,事实上的超管);company 不存在 * 或 ownerId 对不上 → false,调用方一律回 404(不是 403——不暴露"这个 id 存在, * 只是不是你的",见 §四.C.1)。 */ function canAccessCompany(company, user) { if (!company || !user) return false; if (user.role === 'admin') return true; return company.ownerId === user.id; } module.exports = { CompanyService, listCompanyAgents, buildAgentDefinitionBrief, canAccessCompany };