Commit 8421f55f6aea5d91edac0c40ba786657944b8fc7

Authored by yibo
1 parent 3c14a4dd

feat(graph-agent): P0 foundation — system_agents/org_graph_* tables + seed migration

Adds server/services/graph-db.js (new data/graph.sqlite: system_agents,
org_graph_nodes, org_graph_edges) and graph-seed-service.js, which scans
system/agents/, system/templates/, and data/companies/*/company.json into
seed rows without touching company-service.js's existing hire/copy path.

Real repo data diverged from the design doc's original estimates (8 not 10
global agents; 39 not 34 template dirs; system/agents/ and system/templates/
turn out to share 6 ids describing the "same" agent from two different
files) — documented in docs/todo/system-design/27-graph-agent-architecture.md
section 7 for P1/P2 to resolve.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.gitignore
@@ -20,7 +20,7 @@ system/agents/*/logs/ @@ -20,7 +20,7 @@ system/agents/*/logs/
20 system/agents/*/workspace/ 20 system/agents/*/workspace/
21 system/agents/*/uploads/ 21 system/agents/*/uploads/
22 system/agents/*/memory/ 22 system/agents/*/memory/
23 -system/agents/.tasks/ 23 +system/tasklog/
24 24
25 # System templates runtime data 25 # System templates runtime data
26 system/templates/*/conversations/ 26 system/templates/*/conversations/
@@ -88,6 +88,8 @@ system/memory/ @@ -88,6 +88,8 @@ system/memory/
88 88
89 # SQLite databases (runtime data) 89 # SQLite databases (runtime data)
90 *.sqlite 90 *.sqlite
  91 +*.sqlite-wal
  92 +*.sqlite-shm
91 93
92 # System company runtime data 94 # System company runtime data
93 system/data/companies/ 95 system/data/companies/
docs/todo/system-design/27-graph-agent-architecture.md 0 → 100644
  1 +# 27. Graph Agent 架构 — Org Graph / Work Graph 与 System Agent 统一寻址
  2 +
  3 +> **版本**: v0.2(P0 已实现)
  4 +> **日期**: 2026-08-15
  5 +> **状态**: P0 ✅ 已实现(`server/services/graph-db.js` + `graph-seed-service.js`,见第七节);P1 起 📝 设计中/未实现
  6 +> **前置**: [02 四层 Agent 架构](../../done/system-design/02-agent-architecture.md)、[24 公司层架构](../../done/system-design/24-company-layer-architecture.md)、[22 Agent 记忆系统](../../done/system-design/22-agent-memory-system.md)、`server/src/core/devloop/loop-controller.js`(现有 retry-with-reflection 实现)
  7 +
  8 +---
  9 +
  10 +## 一、核心问题
  11 +
  12 +### 现有模型(24 号文档)的局限
  13 +
  14 +24 号文档确立的模型是「System Agent(全局单例)+ Agent Template(可复用蓝图)+ Company Agent(雇佣时从模板 **文件拷贝** 出来的实例)」。这套模型跑通了 Multi-Tenant 公司隔离,但暴露出四个结构性问题:
  15 +
  16 +1. **状态碎片化**:每次 `hire()` 都是一次 `fs.copyFileSync`,同一角色在 10 家公司名下就有 10 份互不相干的 `agent.md`/`memory.md`。模板更新后,已雇佣的实例不会同步,也无法双向学习——A 公司的 QA 遇到的坑,B 公司的 QA 完全学不到。
  17 +2. **无法自我进化**:Agent 的"智能"被锁在一次性拷贝的静态文件里,没有一个机制让它在被反复调用后越来越准。唯一的进化路径是 `depositTemplate()`——训练出一个新 agent,跑 benchmark,≥95 分才存回模板库,这是"生个孩子"而不是"自己变强"。
  18 +3. **协作关系是扁平列表**:`company.json.agents[]` 只是一个角色名数组,没有边、没有汇报关系,更没有"谁的输出喂给谁"的显式结构。
  19 +4. **失败恢复局限在单一场景**:目前唯一的循环恢复机制是 DevLoop 的 `runUnit`(Coder ↔ Tester 两节点、固定方向、`retry_budget` 硬顶 5 次),无法泛化到"任意节点失败后,带着上下文跳回图中任意历史节点重跑"这种通用场景。
  20 +
  21 +### 设计目标
  22 +
  23 +把「company 拥有并复制 agent」改造为「company 引用全局 system agent,并在其上定义**静态协作结构(Org Graph)**与**动态任务执行路径(Work Graph)**」:
  24 +
  25 +1. Agent 是跨公司复用、持续自我进化的一等公民,不是一次性文件拷贝。
  26 +2. 公司的组织结构(Org Graph)与具体任务怎么跑(Work Graph)解耦:前者是company 层面相对稳定的静态定义,后者由 planning agent 针对每个目标动态规划、执行完即归档。
  27 +3. 失败恢复不再局限于"重跑同一个节点":任意节点失败可以携带上下文回退到图中任意历史节点,同时有引擎级别的硬性防死循环机制。
  28 +
  29 +---
  30 +
  31 +## 二、术语定义
  32 +
  33 +| 术语 | 定义 |
  34 +|------|------|
  35 +| **System Agent** | 全局唯一的可执行 Agent 实体。自带 prompt 定义、公共记忆(`memory.md`)、按公司分区的私有历史。是 24 号文档里"System Agent"与"Agent Template"两个概念的合并——不再区分"模板"和"实例",只有"这个 agent,被谁调用过"。 |
  36 +| **scope** | System Agent 的可见性:`public`(跨公司共享,记忆公共累积)或 `company`(私有,只属于 `owner_company_id` 指定的一家公司)。 |
  37 +| **Org Graph** | 公司级别的**静态**协作结构。节点 = 对某个 system agent 的引用;边 = 汇报/协作关系。取代现有 `company.json.agents[]` 扁平列表。 |
  38 +| **Work Graph** | 某个 org 节点收到一个 goal/task 后,现场调用 **work-graph-planning agent** 针对这一具体任务规划出的**动态**执行图。节点 = 对某个 system agent 的一次调用;边带 `success`/`fail` 条件与目标节点。执行完归档,不复用到下一个任务。 |
  39 +| **Loop-Validator** | 一个专职的 system agent,内置带硬编码熔断逻辑的 skill。所有"回退到历史节点"的边必须先经过它:备案 → 审计上次修复是否生效 → 判断修复方案是否被正确传递 → 循环数 > 3 强制终止。 |
  40 +| **Node 内容 / Edge 内容** | 图结构本身(节点、边、状态、指针)存 SQLite;节点的输入输出内容、日志、上下文,依旧是 Markdown 文件,物理落在对应 system agent 的目录下。 |
  41 +
  42 +---
  43 +
  44 +## 三、架构设计
  45 +
  46 +### 3.1 三层模型总览
  47 +
  48 +```
  49 + ┌─────────────────────────────────────┐
  50 + │ System Agent 池 │
  51 + │ (全局唯一,可跨公司复用,自我进化) │
  52 + │ │
  53 + │ [ceo] [coder] [qa-judge] [...] │
  54 + │ ▲ ▲ ▲ │
  55 + └─────┼───────┼─────────┼────────────────┘
  56 + │ │ │ (引用,非复制)
  57 + ┌────────────┴───────┴─────────┴──────────────┐
  58 + │ Company: {companyId} │
  59 + │ │
  60 + │ Org Graph(静态,company 生命周期内相对稳定) │
  61 + │ [CEO 节点] ──reports──> [CTO 节点] ──> ... │
  62 + │ │
  63 + │ Work Graph(动态,per-goal,执行完归档) │
  64 + │ [node1:coder] --success--> [node2:tester] │
  65 + │ --fail-----> [loop-validator]│
  66 + │ │ │
  67 + │ ▼ │
  68 + │ [回退到 node1] │
  69 + └────────────────────────────────────────────────┘
  70 +```
  71 +
  72 +### 3.2 System Agent:唯一执行体与数据隔离
  73 +
  74 +目录结构(取代现有 `system/agents/` 与 `system/templates/` 的分离):
  75 +
  76 +```
  77 +system/agents/{agentId}/
  78 + agent.md / soul.md / identity.md / tools.md # 定义本体,全局唯一
  79 + memory.md # 公共记忆:自我迭代蒸馏出的能力/经验,跨公司共享持续增长
  80 + config.json # { scope: public|company, owner_company_id, status, benchmark_score }
  81 + companies/
  82 + {companyId}/
  83 + memory.md # 该公司私有工作记忆(目标、上下文、汇报关系)
  84 + history/
  85 + {workGraphId}-{nodeId}-{attempt}.md # 该公司触发的每次执行原始记录
  86 + logs/
  87 +```
  88 +
  89 +- **company-scoped(私有)**:`companies/` 下永远只有 owner 一个子目录,代码路径与 public 完全一致,不做特殊分支。
  90 +- **public(公共)**:`companies/` 下有多个公司各自的隔离子目录。
  91 +
  92 +**读取隔离原则**:PromptBuilder 组装某次调用的上下文时,只按 `(agentId, companyId)` 构造路径——公共部分(`agent.md`/`memory.md`)+ 这一家公司自己的 `companies/{companyId}/`。别的公司的目录**从物理路径上就不会被触达**,跟现有 DevLoop CONVENTIONS.md §12「跨 unit 只能读 `../{otherUnit}/contract.json`」是同一种"靠构造方式天然隔离"的思路,不是运行时权限过滤。
  93 +
  94 +**写入分级**:私有 `companies/{id}/memory.md` 每次调用后可自由写,风险局限在一家公司内。公共 `memory.md` 的更新(自我进化)风险更高——写错会污染所有后续调用方——因此需要走一次自省校验(复用 loop-validator 或类似机制),并且像 DevLoop 项目一样纳入 git 版本管理,坏的自我迭代可以回滚。
  95 +
  96 +**新建与升级治理**:Work-graph 执行中如果需要的 system agent 不存在,直接触发创建,默认 `scope=company`(私有)。是否升级为 `public`,复用现有 `hireWithTraining()` → `agent-creator`/`innovator` 训练 → benchmark ≥95 → 升级的门槛链路,只是终点从"写文件到 `system/templates/`"改为"更新 DB 里的 `scope` 字段"。**不新增治理机制,只改落点。**
  97 +
  98 +### 3.3 Org Graph:公司静态结构
  99 +
  100 +- 节点:对某个 system agent 的引用(`org_graph_nodes`),代替 `company.json.agents[]`。
  101 +- 边:汇报/协作关系(`org_graph_edges`),公司创建/招聘时由 CEO 或 HR agent 维护。
  102 +- "招聘"语义变化:不再是"拷贝一份文件",而是"在 org graph 里加一个指向某 system agent 的引用节点"。
  103 +
  104 +### 3.4 Work Graph:任务动态执行图
  105 +
  106 +1. 某 org 节点收到一个 goal/task。
  107 +2. 该节点调用 **work-graph-planning agent**,针对这个具体任务规划出一张图:节点指向要执行的 system agent,边带 `success`/`fail` 条件。
  108 +3. **目标节点范围不受 org graph 约束,只受 system agent 池约束**——planning agent 可以调用任意 public agent、company 自己私有的 agent,或判断需要新建一个(走 3.2 的创建+治理链路)。
  109 +4. **回退边必须显式经过 loop-validator 节点**——即"节点A --fail--> loop-validator --> 节点B",而不是"节点A --fail(隐藏回退属性)--> 节点B"。这是画图时的**结构要求**,不是引擎运行时才附加的隐藏逻辑。
  110 +5. **执行前结构校验**:图规划完成后、进入执行前,引擎做一次静态校验——任何边如果目标节点在图中已存在其他指向它的边(即会构成"回到历史节点"),但没有经过 loop-validator 节点,直接拒绝这张图,不进入执行。类比现有 `npm run spec:check` 的"结构先校验,再允许跑"模式。这是对"planning agent 应该自觉画上 validator"这条规则的**引擎级兜底**,防止规划疏漏导致死循环绕过审计。
  111 +
  112 +### 3.5 Loop-Validator:死循环兜底
  113 +
  114 +- 本身是一个 system agent(可以是 public,因为"判断循环是否收敛"这个能力本质上与具体业务无关,值得跨公司共享进化)。
  115 +- 自带 skill,skill 内部包含**硬编码的计数器/熔断逻辑**,不是纯靠一次 LLM 推理决定"要不要停":
  116 + 1. **备案**:任意节点即将进入回退循环前,先向 loop-validator 登记这次尝试。
  117 + 2. **审计**:目标节点重跑完成后,loop-validator 比对这次结果与上次失败原因,判断是否真的修复。
  118 + 3. **修复方案传递校验**:如果没修复,检查这次回退时是否把"应该如何修复"的方案正确带给了下一轮,而不是盲目重跑。
  119 + 4. **硬顶**:循环次数 > 3,无论 LLM 判断如何,skill 内的计数器强制终止,不进入第 4 次回退。
  120 +- **引擎级不变量**:任何回退边必须路由经过 loop-validator,这一点不能被 planning agent 在规划阶段跳过——由 3.4 第 5 点的执行前校验保证。
  121 +
  122 +### 3.6 与执行链路的关系
  123 +
  124 +节点执行本质上仍是现有 `AgentManager.callAgent()` 的调用,只是 `getAgentConfig()` 的输入从"`companyId:roleName` 路径拼接"变成"`(system_agent_id, companyId)` 的引用查表",`PromptBuilder.build()` 在装配 prompt 时按 3.2 的隔离规则拼装公共定义 + 私有语境。**执行引擎(session-pool、runtime-resolver、stream-executor)不需要改**,改的是"config 从哪来"这一层。
  125 +
  126 +---
  127 +
  128 +## 四、数据模型(Schema 草稿)
  129 +
  130 +DB 只存**关系与状态指针**,节点/边的具体内容(任务描述、输出、日志)始终是 Markdown 文件,按 3.2 的目录约定物理落在对应 system agent 目录下(**不在 DB 里存文件路径字符串,路径由 `(system_agent_id, company_id, work_graph_id, node_id, attempt)` 确定性推导**,避免路径漂移)。
  131 +
  132 +```sql
  133 +-- 全局 system agent 注册表
  134 +CREATE TABLE system_agents (
  135 + id TEXT PRIMARY KEY,
  136 + name TEXT NOT NULL,
  137 + scope TEXT NOT NULL CHECK (scope IN ('public', 'company')),
  138 + owner_company_id TEXT, -- scope='company' 时必填
  139 + status TEXT NOT NULL DEFAULT 'active', -- active | training | deprecated
  140 + benchmark_score REAL,
  141 + created_at INTEGER NOT NULL,
  142 + updated_at INTEGER NOT NULL
  143 +);
  144 +
  145 +-- 公司静态协作结构
  146 +CREATE TABLE org_graph_nodes (
  147 + id TEXT PRIMARY KEY,
  148 + company_id TEXT NOT NULL,
  149 + system_agent_id TEXT NOT NULL REFERENCES system_agents(id),
  150 + role_name TEXT NOT NULL,
  151 + created_at INTEGER NOT NULL
  152 +);
  153 +
  154 +CREATE TABLE org_graph_edges (
  155 + id TEXT PRIMARY KEY,
  156 + company_id TEXT NOT NULL,
  157 + from_node_id TEXT NOT NULL REFERENCES org_graph_nodes(id),
  158 + to_node_id TEXT NOT NULL REFERENCES org_graph_nodes(id),
  159 + relation_type TEXT NOT NULL -- reports_to | collaborates_with
  160 +);
  161 +
  162 +-- 单次任务的动态执行图
  163 +CREATE TABLE work_graphs (
  164 + id TEXT PRIMARY KEY,
  165 + company_id TEXT NOT NULL,
  166 + triggered_by_org_node_id TEXT REFERENCES org_graph_nodes(id),
  167 + goal_task_id TEXT,
  168 + status TEXT NOT NULL DEFAULT 'planning', -- planning | running | passed | failed | blocked
  169 + created_at INTEGER NOT NULL,
  170 + updated_at INTEGER NOT NULL
  171 +);
  172 +
  173 +CREATE TABLE work_graph_nodes (
  174 + id TEXT PRIMARY KEY,
  175 + work_graph_id TEXT NOT NULL REFERENCES work_graphs(id),
  176 + system_agent_id TEXT NOT NULL REFERENCES system_agents(id),
  177 + node_kind TEXT NOT NULL DEFAULT 'task', -- task | loop_validator
  178 + status TEXT NOT NULL DEFAULT 'pending', -- pending | running | success | fail | blocked
  179 + attempt INTEGER NOT NULL DEFAULT 1
  180 +);
  181 +
  182 +CREATE TABLE work_graph_edges (
  183 + id TEXT PRIMARY KEY,
  184 + work_graph_id TEXT NOT NULL REFERENCES work_graphs(id),
  185 + from_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id),
  186 + to_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id),
  187 + condition TEXT NOT NULL CHECK (condition IN ('success', 'fail')),
  188 + edge_kind TEXT NOT NULL DEFAULT 'forward' -- forward | backward(backward 必须 to_node 是 loop_validator 或来自 loop_validator)
  189 +);
  190 +```
  191 +
  192 +`edge_kind='backward'` 由 planning agent 在画图时显式标注;执行前校验(3.4 第 5 点)会独立复核——不信任标注本身,而是遍历图结构确认任何指向"已在图中更早出现过"的节点的边,必须满足"经过 `node_kind='loop_validator'`"这一条件,双重保险。
  193 +
  194 +---
  195 +
  196 +## 五、与现有系统的关系 / 迁移策略
  197 +
  198 +| 现有组件 | 变化 |
  199 +|----------|------|
  200 +| `server/services/company-service.js` 的 `hire()` / `_copyTemplate()` | 废弃文件拷贝,替换为"创建/复用 system agent + 在 org_graph 加引用节点" |
  201 +| `system/templates/` | 逐步废弃,内容并入 `system_agents`(`scope=public`),`_registry.json` 被 DB 表取代 |
  202 +| `AgentManager.getAgentConfig()` | 输入从路径拼接改为 `(system_agent_id, company_id)` 查表 + 引用 3.2 目录约定 |
  203 +| `PromptBuilder.build()` | 增加"公共定义 + 私有语境"两段拼装逻辑 |
  204 +| `hireWithTraining()` / `agent-creator` / `innovator` / `depositTemplate()` | 保留,只改落点(写 DB scope 字段而非写模板目录) |
  205 +| DevLoop `loop-controller.js` 的 `runUnit` | 可视为 Work Graph 的一个特例(固定两节点、单方向重试)。本次不强制收编,留作后续 Phase 6 的可选项 |
  206 +
  207 +---
  208 +
  209 +## 六、开发计划(Phased Roadmap)
  210 +
  211 +| Phase | 目标 | 交付物 | 验收标准 |
  212 +|-------|------|--------|----------|
  213 +| **P0 地基** ✅ | 建表 + 现有数据迁移,不改变任何现有执行路径 | `system_agents`/`org_graph_*` 表;把现有 `system/agents/` + `system/templates/` 目录扫描导入为种子数据(`system_agents` 行,`scope` 按当前所属推断) | 迁移脚本跑完,`system_agents` 表行数与现有 agent+模板目录数一致(**实际行数与本文档最初估计不同,见第七节**);现有 `company-service.js` 行为不变(并行只读验证,已确认零改动) |
  214 +| **P1 虚拟引用** | 停止文件拷贝,改为引用 | 改造 `AgentManager.getAgentConfig()` + `PromptBuilder.build()` 支持 `(agentId, companyId)` 寻址;`company-service.hire()` 改为创建 org_graph 引用节点而非 `_copyTemplate()`。细化需求与设计见 [27-P1 虚拟引用寻址](./27-p1-virtual-reference.md) | 新雇佣的 agent 不再产生文件拷贝,调用时能正确拼装公共+私有上下文;旧公司(仍是拷贝模式)继续可跑,不强制迁移存量数据 |
  215 +| **P2 Org Graph** | `company.json.agents[]` 迁移为图结构 | `org_graph_nodes`/`edges` 落地;HR/CEO 招聘走新表 | 现有 Company Dashboard 前端能正确读出 org graph 并渲染(哪怕暂时按列表展示) |
  216 +| **P3 Work Graph 最小闭环** | 单向执行,先不做回退 | work-graph-planning agent(system agent,新建);`work_graphs`/`nodes`/`edges` 表;顺序执行器(success → next,fail → 停止/标记 blocked,暂不回退) | 一个 goal 能被规划成 2-3 节点的图并顺序跑完,状态正确落库 |
  217 +| **P4 回退 + Loop-Validator** | 引入死循环防护 | loop-validator system agent + 内置熔断 skill;`edge_kind=backward` 支持;3.4 第 5 点的执行前结构校验 | 人为构造一个"总是失败"的节点,验证第 4 次回退被 loop-validator 强制终止,且校验能拒绝"回退边未经过 validator"的非法图 |
  218 +| **P5 自我进化** | 公共记忆蒸馏机制 | 公共 `memory.md` 写入走自省校验 + git 版本化;company-scoped → public 的升级门槛复用现有 benchmark ≥95 链路 | 同一 public agent 被两家公司调用后,`memory.md` 有可观察的增量更新,且能 git revert 到升级前版本 |
  219 +| **P6(可选)DevLoop 收编** | 统一循环逻辑 | 把 `loop-controller.js`/`unit-registry.js` 重构为 Work Graph 引擎上的一个具体图模板 | DevLoop 现有测试套件(`server/tests/devloop/**`)在新引擎上全部通过,无需单独维护两套循环代码 |
  220 +
  221 +**依赖顺序是强顺序的**:P1 依赖 P0 的数据先迁移进 DB;P3 的执行器依赖 P1 的引用寻址跑通;P4 的回退依赖 P3 的顺序执行先稳定。P5、P6 相对独立,可以在 P4 稳定后并行推进或往后延。
  222 +
  223 +每个 Phase 建议保留旧路径作为 fallback(不删除 `_copyTemplate()`/`system/templates/` 扫描逻辑),直到下一 Phase 验收通过,避免中途出问题时无法回退。
  224 +
  225 +---
  226 +
  227 +## 七、P0 实施发现(2026-08-15)
  228 +
  229 +P0 已实现(`server/services/graph-db.js` + `server/services/graph-seed-service.js` + `scripts/graph-seed.js`,`npm run graph:seed` 可跑),经 code review 通过。实施过程中对真实仓库数据做了独立核实,几个数字和本文档最初的估计(来自第一轮调研)不一致,记录如下,供 P1/P2 设计时参考:
  230 +
  231 +| 项 | 本文档最初估计 | 真实数据 |
  232 +|---|---|---|
  233 +| `system/agents/` 里真正的 agent(含 `agent.md`) | 10 | **8** —— `screenwriter/`、`youtube-crawler/` 两个目录没有 `agent.md`,不满足"是一个可执行 agent"的最低条件,被迁移脚本正确排除 |
  234 +| `system/templates/` 目录数 | 34 | **39** |
  235 +| 最终落库的 `source_kind='template'` 行数 | 34 | **33**(39 减去下面的命名空间重叠问题里被 `global_agent` 占用的 6 个) |
  236 +| `_registry.json` 里 `benchmark_score` 非空的条目 | 9 | **3**(`ceo`=97/`qa-judge`=95/`base`=90)——另外 6 个 `devloop-*` 虽然在 registry 里能匹配上 id,但 registry 原始数据里 `benchmarkScore` 本来就是 `null`,不是脚本漏读 |
  237 +| `idearun` 公司 6 个角色里"公司专属、无对应模板"的数量 | 4 | **0** —— 6 个角色现在全部能在 `system/templates/*` 里找到同名目录,全部复用已有行,没有触发新建 `scope='company'` 记录 |
  238 +
  239 +### 关键发现:`system/agents/` 与 `system/templates/` 命名空间不是互斥的
  240 +
  241 +有 6 个 id(`agent-creator`/`digester`/`evolver`/`evolver-youtube-crawler`/`hunter`/`innovator`)**在两个目录下都存在**,描述的是概念上"同一个" agent,但物理上是两份独立的文件(内容很可能已经不同步)。这一点在最初两轮只读调研(用于写这份文档正文)里没有被发现,是 P0 实际扫描真实目录时才暴露出来的。
  242 +
  243 +P0 阶段的处理方式是**迁移脚本层面的临时保护**:`system_agents.id` 是跨 `source_kind` 共享的单一 TEXT 主键,`seedTemplates()` 遇到一个已经被 `seedGlobalAgents()` 占用的 id 时会跳过写入(不覆盖),保证 `global_agent` 优先、幂等可重跑。这只是避免了"数据被静默覆盖"的坏结果,**没有回答"这 6 个 agent 到底该以哪份文件为准"这个产品问题**——`system/agents/{id}/agent.md` 和 `system/templates/{id}/agent.md` 目前很可能内容不同,P1 做虚拟引用寻址、P2 做 org graph 招聘的时候都会撞到这批 id,需要在那之前决定:是人工合并成一份、保留 `global_agent` 版本视为权威并废弃 template 版本,还是干脆认为这 6 个本来就是两个不同的东西(比如一个是"基础设施 agent"、一个是"可招聘的角色模板")、id 冲突只是命名巧合。这个决定应该在 P1 动手改 `AgentManager`/`PromptBuilder` 之前做,否则虚拟引用会读到不确定是哪一份的 `agent.md`。
  244 +
  245 +### 关联文档
  246 +
  247 +P1(虚拟引用寻址)的详细需求与设计已有独立草稿:[27-P1 虚拟引用寻址](./27-p1-virtual-reference.md)。
package.json
@@ -31,6 +31,7 @@ @@ -31,6 +31,7 @@
31 "analyze": "node server/src/utils/analyze-log.js", 31 "analyze": "node server/src/utils/analyze-log.js",
32 "parse": "node server/src/utils/parse-output.js", 32 "parse": "node server/src/utils/parse-output.js",
33 "skills:sync": "node scripts/sync-skills.js", 33 "skills:sync": "node scripts/sync-skills.js",
  34 + "graph:seed": "node scripts/graph-seed.js",
34 "spec:extract": "node scripts/extract-spec.js", 35 "spec:extract": "node scripts/extract-spec.js",
35 "spec:check": "node scripts/extract-spec.js --check", 36 "spec:check": "node scripts/extract-spec.js --check",
36 "whatsapp:auth": "node skills/whatsapp-bot/auth.js", 37 "whatsapp:auth": "node skills/whatsapp-bot/auth.js",
scripts/graph-seed.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +
  3 +/**
  4 + * graph-seed.js — seeds data/graph.sqlite from the on-disk agent/template/company sources.
  5 + *
  6 + * 依次跑 seedGlobalAgents()(T1)→ seedTemplates()(T2)→ seedCompanyAgents()(T3)
  7 + * → verifyCounts()(T4,独立重新扫描目录/文件做行数交叉核对)。
  8 + * 打印按 source_kind 分组的汇总报告;核对不一致时以非零状态码退出。
  9 + *
  10 + * 用法:
  11 + * node scripts/graph-seed.js
  12 + */
  13 +
  14 +const { seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts } = require('../server/services/graph-seed-service');
  15 +
  16 +function main() {
  17 + console.log('[graph-seed] 开始种子导入\n');
  18 +
  19 + const globalAgents = seedGlobalAgents();
  20 + console.log(`[global_agent] 扫描 system/agents/* 目录: ${globalAgents.scanned} 个,含 agent.md 且已导入: ${globalAgents.seeded} 个`);
  21 + console.log(` ids: ${globalAgents.ids.join(', ')}`);
  22 +
  23 + const templates = seedTemplates();
  24 + console.log(`\n[template] 扫描 system/templates/* 目录: ${templates.scanned} 个,已导入: ${templates.seeded} 个`);
  25 + console.log(` registry 匹配到 benchmark_score 的 ids (${templates.registryMatched.length}): ${templates.registryMatched.join(', ')}`);
  26 + if (templates.skipped.length > 0) {
  27 + console.log(` ⚠️ id 与其他 source_kind 冲突,已跳过写入 (${templates.skipped.length}): ${templates.skipped.map(s => `${s.id}(已属于 ${s.existingSourceKind})`).join(', ')}`);
  28 + }
  29 +
  30 + const companyAgents = seedCompanyAgents();
  31 + console.log(`\n[company_hired] 扫描到 ${companyAgents.companies.length} 家有 company.json 的公司: ${companyAgents.companies.join(', ')}`);
  32 + console.log(` org_graph_nodes 本次新建: ${companyAgents.nodesCreated} 行,已存在跳过: ${companyAgents.nodesExisting} 行`);
  33 + console.log(` 引用已有 system_agents 行 (${companyAgents.reusedSystemAgents.length}): ${companyAgents.reusedSystemAgents.join(', ')}`);
  34 + console.log(` 新建 scope='company' 行 (${companyAgents.newSystemAgents.length}): ${companyAgents.newSystemAgents.join(', ')}`);
  35 +
  36 + console.log('\n[verify] 行数对账(独立重新扫描目录/文件,不复用上面的计数器)');
  37 + const v = verifyCounts();
  38 + console.log(` system_agents[global_agent]: 期望 ${v.expected.global_agent},实际 ${v.actual.global_agent}`);
  39 + console.log(` system_agents[template]: 期望 ${v.expected.template},实际 ${v.actual.template}`);
  40 + console.log(` system_agents[company_hired]:期望 ${v.expected.company_hired},实际 ${v.actual.company_hired}`);
  41 + const companyIds = Object.keys({ ...v.expected.nodesPerCompany, ...v.actual.nodesPerCompany });
  42 + for (const companyId of companyIds) {
  43 + const expected = v.expected.nodesPerCompany[companyId] || 0;
  44 + const actual = v.actual.nodesPerCompany[companyId] || 0;
  45 + console.log(` org_graph_nodes[${companyId}]: 期望 ${expected},实际 ${actual}`);
  46 + }
  47 +
  48 + if (!v.ok) {
  49 + console.error('\n❌ 行数对账失败,发现以下不一致:');
  50 + for (const m of v.mismatches) {
  51 + console.error(` - ${m.check}: 期望 ${m.expected},实际 ${m.actual}`);
  52 + }
  53 + process.exitCode = 1;
  54 + return;
  55 + }
  56 +
  57 + console.log('\n✅ 种子导入完成,行数对账通过');
  58 +}
  59 +
  60 +main();
server/services/graph-db.js 0 → 100644
  1 +/**
  2 + * Graph Database — SQLite for the Graph Agent architecture (P0)
  3 + *
  4 + * 表结构:
  5 + * system_agents 全局 / 模板 / 公司专属 agent 元数据(TEXT PRIMARY KEY = 目录名)
  6 + * org_graph_nodes 公司组织图节点(company 对某个 system_agent 的引用 + 角色名)
  7 + * org_graph_edges 公司组织图边(节点间的关系)
  8 + *
  9 + * P0 阶段:只加不改,不涉及任何现有执行路径。
  10 + */
  11 +
  12 +const Database = require('better-sqlite3');
  13 +const path = require('path');
  14 +const fs = require('fs');
  15 +
  16 +const DATA_DIR = path.resolve(__dirname, '../../data');
  17 +const DB_PATH = process.env.GRAPH_DB_PATH || path.join(DATA_DIR, 'graph.sqlite');
  18 +
  19 +let db = null;
  20 +
  21 +function getDb() {
  22 + if (db) return db;
  23 +
  24 + if (!fs.existsSync(DATA_DIR)) {
  25 + fs.mkdirSync(DATA_DIR, { recursive: true });
  26 + }
  27 +
  28 + db = new Database(DB_PATH);
  29 + db.pragma('journal_mode = WAL');
  30 + db.pragma('foreign_keys = ON');
  31 +
  32 + db.exec(`
  33 + CREATE TABLE IF NOT EXISTS system_agents (
  34 + id TEXT PRIMARY KEY,
  35 + name TEXT NOT NULL,
  36 + scope TEXT NOT NULL CHECK (scope IN ('public','company')),
  37 + owner_company_id TEXT,
  38 + source_kind TEXT NOT NULL CHECK (source_kind IN ('global_agent','template','company_hired')),
  39 + legacy_path TEXT NOT NULL,
  40 + status TEXT NOT NULL DEFAULT 'active',
  41 + benchmark_score REAL,
  42 + created_at TEXT NOT NULL,
  43 + updated_at TEXT NOT NULL
  44 + );
  45 +
  46 + CREATE TABLE IF NOT EXISTS org_graph_nodes (
  47 + id INTEGER PRIMARY KEY AUTOINCREMENT,
  48 + company_id TEXT NOT NULL,
  49 + system_agent_id TEXT NOT NULL REFERENCES system_agents(id),
  50 + role_name TEXT NOT NULL,
  51 + created_at TEXT NOT NULL
  52 + );
  53 +
  54 + CREATE TABLE IF NOT EXISTS org_graph_edges (
  55 + id INTEGER PRIMARY KEY AUTOINCREMENT,
  56 + company_id TEXT NOT NULL,
  57 + from_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id),
  58 + to_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id),
  59 + relation_type TEXT NOT NULL
  60 + );
  61 + `);
  62 +
  63 + return db;
  64 +}
  65 +
  66 +module.exports = {
  67 + getDb,
  68 +};
server/services/graph-seed-service.js 0 → 100644
  1 +/**
  2 + * Graph Seed Service — populates `data/graph.sqlite` from existing on-disk
  3 + * agent/template/company sources (P0, read-only w.r.t. the legacy sources).
  4 + *
  5 + * seedGlobalAgents() — system/agents/* → system_agents (source_kind='global_agent')
  6 + * seedTemplates() — system/templates/* → system_agents (source_kind='template')
  7 + * seedCompanyAgents() — data/companies/{id}/company.json.agents[] → system_agents (source_kind='company_hired') + org_graph_nodes
  8 + * (T4 adds verifyCounts() here later.)
  9 + *
  10 + * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。
  11 + */
  12 +
  13 +const fs = require('fs');
  14 +const path = require('path');
  15 +const { getDb } = require('./graph-db');
  16 +
  17 +const ROOT = path.resolve(__dirname, '../..');
  18 +const AGENTS_DIR = path.join(ROOT, 'system', 'agents');
  19 +const TEMPLATES_DIR = path.join(ROOT, 'system', 'templates');
  20 +const TEMPLATES_REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json');
  21 +const COMPANIES_DIR = path.join(ROOT, 'data', 'companies');
  22 +
  23 +/**
  24 + * 扫描 system/agents/*,过滤掉没有 agent.md 的目录(排除 templates/ 等杂物),
  25 + * 逐个 upsert 到 system_agents,scope='public',source_kind='global_agent'。
  26 + *
  27 + * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。
  28 + * @returns {{ scanned: number, seeded: number, ids: string[] }}
  29 + */
  30 +function seedGlobalAgents() {
  31 + const db = getDb();
  32 +
  33 + const entries = fs.existsSync(AGENTS_DIR)
  34 + ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
  35 + : [];
  36 +
  37 + const agentDirs = entries.filter(d =>
  38 + fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md'))
  39 + );
  40 +
  41 + const now = new Date().toISOString();
  42 +
  43 + const upsert = db.prepare(`
  44 + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
  45 + VALUES (@id, @name, 'public', NULL, 'global_agent', @legacy_path, @status, NULL, @created_at, @updated_at)
  46 + ON CONFLICT(id) DO UPDATE SET
  47 + name = excluded.name,
  48 + legacy_path = excluded.legacy_path,
  49 + status = excluded.status,
  50 + updated_at = excluded.updated_at
  51 + `);
  52 +
  53 + const ids = [];
  54 + const seedOne = db.transaction((dirEntries) => {
  55 + for (const entry of dirEntries) {
  56 + const id = entry.name;
  57 + const dir = path.join(AGENTS_DIR, id);
  58 + const configPath = path.join(dir, 'config.json');
  59 +
  60 + let config = null;
  61 + if (fs.existsSync(configPath)) {
  62 + try {
  63 + config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
  64 + } catch {
  65 + config = null; // malformed config.json → fall back to defaults
  66 + }
  67 + }
  68 +
  69 + const name = (config && config.name) || id;
  70 + const status = (config && config.status) || 'active';
  71 +
  72 + upsert.run({
  73 + id,
  74 + name,
  75 + legacy_path: `system/agents/${id}`,
  76 + status,
  77 + created_at: now,
  78 + updated_at: now,
  79 + });
  80 + ids.push(id);
  81 + }
  82 + });
  83 +
  84 + seedOne(agentDirs);
  85 +
  86 + return { scanned: entries.length, seeded: ids.length, ids };
  87 +}
  88 +
  89 +/**
  90 + * 扫描 system/templates/*(目录扫描才是数据源头),逐个 upsert 到 system_agents,
  91 + * scope='public', source_kind='template'。
  92 + *
  93 + * `_registry.json` 只用来补充可选的 benchmark_score:按 id 匹配上的条目用其
  94 + * `benchmarkScore` 字段(registry 里该字段本身可能是 null),匹配不上的一律留 NULL。
  95 + * 容忍模板目录完全没有 config.json(如 ceo/ 只有 plan.md+todo.md):
  96 + * 缺失时 name 默认目录名、status 默认 'active'。
  97 + *
  98 + * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。
  99 + *
  100 + * id 命名空间跨源共享(`system/agents/` 与 `system/templates/` 有同名目录,如
  101 + * `evolver`/`hunter`/`agent-creator`):当扫到的模板 id 已经被别的 source_kind
  102 + * 占用时(例如 T1 seedGlobalAgents 先写入的 global_agent 行),**跳过写入**,
  103 + * 不覆盖那一行——否则会把 global_agent 行的 legacy_path/name/status 静默篡改成
  104 + * 模板目录的数据,同一行却仍标着 source_kind='global_agent',数据会被污染。
  105 + * 只有当已存在的行本身就是 source_kind='template' 时才走 UPDATE(保证本函数自身可重复执行)。
  106 + *
  107 + * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], skipped: {id: string, existingSourceKind: string}[] }}
  108 + */
  109 +function seedTemplates() {
  110 + const db = getDb();
  111 +
  112 + const entries = fs.existsSync(TEMPLATES_DIR)
  113 + ? fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
  114 + : [];
  115 +
  116 + let registryById = new Map();
  117 + if (fs.existsSync(TEMPLATES_REGISTRY_PATH)) {
  118 + try {
  119 + const registry = JSON.parse(fs.readFileSync(TEMPLATES_REGISTRY_PATH, 'utf-8'));
  120 + if (Array.isArray(registry)) {
  121 + registryById = new Map(registry.map(entry => [entry.id, entry]));
  122 + }
  123 + } catch {
  124 + registryById = new Map(); // malformed _registry.json → no enrichment, still seed dirs
  125 + }
  126 + }
  127 +
  128 + const now = new Date().toISOString();
  129 +
  130 + const findExisting = db.prepare(`SELECT source_kind FROM system_agents WHERE id = ?`);
  131 +
  132 + const upsert = db.prepare(`
  133 + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
  134 + VALUES (@id, @name, 'public', NULL, 'template', @legacy_path, @status, @benchmark_score, @created_at, @updated_at)
  135 + ON CONFLICT(id) DO UPDATE SET
  136 + name = excluded.name,
  137 + legacy_path = excluded.legacy_path,
  138 + status = excluded.status,
  139 + benchmark_score = excluded.benchmark_score,
  140 + updated_at = excluded.updated_at
  141 + `);
  142 +
  143 + const ids = [];
  144 + const registryMatched = [];
  145 + const skipped = [];
  146 + const seedAll = db.transaction((dirEntries) => {
  147 + for (const entry of dirEntries) {
  148 + const id = entry.name;
  149 +
  150 + const existing = findExisting.get(id);
  151 + if (existing && existing.source_kind !== 'template') {
  152 + skipped.push({ id, existingSourceKind: existing.source_kind });
  153 + continue; // id already owned by another source (e.g. global_agent) — don't clobber it
  154 + }
  155 +
  156 + const dir = path.join(TEMPLATES_DIR, id);
  157 + const configPath = path.join(dir, 'config.json');
  158 +
  159 + let config = null;
  160 + if (fs.existsSync(configPath)) {
  161 + try {
  162 + config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
  163 + } catch {
  164 + config = null; // malformed config.json → fall back to defaults
  165 + }
  166 + }
  167 +
  168 + const name = (config && config.name) || id;
  169 + const status = (config && config.status) || 'active';
  170 +
  171 + const registryEntry = registryById.get(id);
  172 + const benchmark_score = registryEntry ? (registryEntry.benchmarkScore ?? null) : null;
  173 + if (registryEntry) registryMatched.push(id);
  174 +
  175 + upsert.run({
  176 + id,
  177 + name,
  178 + legacy_path: `system/templates/${id}`,
  179 + status,
  180 + benchmark_score,
  181 + created_at: now,
  182 + updated_at: now,
  183 + });
  184 + ids.push(id);
  185 + }
  186 + });
  187 +
  188 + seedAll(entries);
  189 +
  190 + return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped };
  191 +}
  192 +
  193 +/**
  194 + * 扫描 data/companies/{id}/company.json,对每个 company 的 `agents[]`(角色名字符串
  195 + * 扁平数组)逐个:
  196 + * - 若 system_agents 里已存在同 id 的行(来自 T1/T2 的 global_agent/template)→
  197 + * 只建一条 org_graph_nodes 指过去。
  198 + * - 若不存在 → 先插入一条新的 system_agents 行(scope='company',
  199 + * owner_company_id=companyId, source_kind='company_hired',
  200 + * legacy_path='data/companies/{companyId}/agents/{name}'),再建 org_graph_nodes。
  201 + *
  202 + * `org_graph_edges` 本阶段留空——company.json 目前没有汇报关系数据,是预期状态,不是 bug。
  203 + *
  204 + * Idempotent:
  205 + * - system_agents 用 INSERT ... ON CONFLICT(id) DO NOTHING(company_hired 行一旦建立
  206 + * 不应被后续扫描覆盖,行为上等同"首次发现即定型")。
  207 + * - org_graph_nodes 没有自然唯一键,靠先查后插(company_id + system_agent_id + role_name
  208 + * 三元组)去重,避免重复跑脚本时行数上涨。
  209 + *
  210 + * @returns {{ companies: string[], nodesCreated: number, nodesExisting: number,
  211 + * newSystemAgents: string[], reusedSystemAgents: string[] }}
  212 + */
  213 +function seedCompanyAgents() {
  214 + const db = getDb();
  215 +
  216 + const companyDirs = fs.existsSync(COMPANIES_DIR)
  217 + ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
  218 + : [];
  219 +
  220 + const now = new Date().toISOString();
  221 +
  222 + const findAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`);
  223 + const insertAgent = db.prepare(`
  224 + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
  225 + VALUES (@id, @name, 'company', @owner_company_id, 'company_hired', @legacy_path, 'active', NULL, @created_at, @updated_at)
  226 + ON CONFLICT(id) DO NOTHING
  227 + `);
  228 + const findNode = db.prepare(`
  229 + SELECT id FROM org_graph_nodes WHERE company_id = ? AND system_agent_id = ? AND role_name = ?
  230 + `);
  231 + const insertNode = db.prepare(`
  232 + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
  233 + VALUES (@company_id, @system_agent_id, @role_name, @created_at)
  234 + `);
  235 +
  236 + const companies = [];
  237 + let nodesCreated = 0;
  238 + let nodesExisting = 0;
  239 + const newSystemAgents = [];
  240 + const reusedSystemAgents = [];
  241 +
  242 + const seedAll = db.transaction((dirEntries) => {
  243 + for (const entry of dirEntries) {
  244 + const companyId = entry.name;
  245 + const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json');
  246 + if (!fs.existsSync(companyJsonPath)) continue; // no company.json → not a real company yet
  247 +
  248 + let companyJson;
  249 + try {
  250 + companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8'));
  251 + } catch {
  252 + continue; // malformed company.json → skip, don't seed partial data
  253 + }
  254 +
  255 + const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : [];
  256 + companies.push(companyId);
  257 +
  258 + for (const roleName of roleNames) {
  259 + const existing = findAgent.get(roleName);
  260 + if (existing) {
  261 + reusedSystemAgents.push(roleName);
  262 + } else {
  263 + insertAgent.run({
  264 + id: roleName,
  265 + name: roleName,
  266 + owner_company_id: companyId,
  267 + legacy_path: `data/companies/${companyId}/agents/${roleName}`,
  268 + created_at: now,
  269 + updated_at: now,
  270 + });
  271 + newSystemAgents.push(roleName);
  272 + }
  273 +
  274 + // role_name 目前恒等于 system_agent_id:company.json.agents[] 现在只是纯角色名字符串数组,
  275 + // 没有独立的"角色名 vs 引用哪个 agent"区分。P2 引入 org_graph 编辑能力后两者会分开。
  276 + const existingNode = findNode.get(companyId, roleName, roleName);
  277 + if (existingNode) {
  278 + nodesExisting += 1;
  279 + } else {
  280 + insertNode.run({
  281 + company_id: companyId,
  282 + system_agent_id: roleName,
  283 + role_name: roleName,
  284 + created_at: now,
  285 + });
  286 + nodesCreated += 1;
  287 + }
  288 + }
  289 + }
  290 + });
  291 +
  292 + seedAll(companyDirs);
  293 +
  294 + return { companies, nodesCreated, nodesExisting, newSystemAgents, reusedSystemAgents };
  295 +}
  296 +
  297 +/**
  298 + * 独立行数交叉核对(T4)——不调用上面三个 seed 函数、不复用它们的返回计数器,
  299 + * 而是从零重新扫描 system/agents/、system/templates/、data/companies/*\/company.json,
  300 + * 按种子逻辑描述的同一套优先级规则(global_agent 优先于 template 优先于 company_hired)
  301 + * 独立推导出"应该"落在 system_agents/org_graph_nodes 里的行数,
  302 + * 再拿去和 DB 里 `SELECT source_kind, COUNT(*) ... GROUP BY source_kind` 以及
  303 + * `SELECT company_id, COUNT(*) FROM org_graph_nodes GROUP BY company_id` 的真实结果比对。
  304 + *
  305 + * 这样一来,如果 seedGlobalAgents/seedTemplates/seedCompanyAgents 内部逻辑跑偏
  306 + * (例如 agent.md 过滤条件写错、冲突跳过逻辑失效、company 角色去重出错),
  307 + * 这里独立算出来的期望值不会跟着一起错,能捕捉到。
  308 + *
  309 + * @returns {{ ok: boolean, mismatches: Array<{check: string, expected: number, actual: number}>,
  310 + * expected: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object<string, number> },
  311 + * actual: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object<string, number> } }}
  312 + */
  313 +function verifyCounts() {
  314 + const db = getDb();
  315 +
  316 + // ── 独立重新扫描 system/agents/(含 agent.md 才算数,与 T1 判定规则一致,但独立实现)──
  317 + const agentEntries = fs.existsSync(AGENTS_DIR)
  318 + ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
  319 + : [];
  320 + const globalIds = new Set(
  321 + agentEntries
  322 + .filter(d => fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md')))
  323 + .map(d => d.name)
  324 + );
  325 +
  326 + // ── 独立重新扫描 system/templates/ ──
  327 + const templateEntries = fs.existsSync(TEMPLATES_DIR)
  328 + ? fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
  329 + : [];
  330 + const templateDirIds = templateEntries.map(d => d.name);
  331 + // id 命名空间跨源共享:与 global_agent 同名的模板目录会被 seedTemplates 冲突保护跳过,
  332 + // 不落地为 template 行——独立复算这条精确规则,而不是直接拿模板目录总数当期望值。
  333 + const expectedTemplateIds = new Set(templateDirIds.filter(id => !globalIds.has(id)));
  334 +
  335 + // ── 独立重新扫描 data/companies/*/company.json ──
  336 + const companyDirs = fs.existsSync(COMPANIES_DIR)
  337 + ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
  338 + : [];
  339 +
  340 + const knownIds = new Set([...globalIds, ...expectedTemplateIds]);
  341 + const expectedNewCompanyHiredIds = new Set();
  342 + const expectedNodesPerCompany = {};
  343 +
  344 + for (const entry of companyDirs) {
  345 + const companyId = entry.name;
  346 + const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json');
  347 + if (!fs.existsSync(companyJsonPath)) continue;
  348 +
  349 + let companyJson;
  350 + try {
  351 + companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8'));
  352 + } catch {
  353 + continue; // 与 seedCompanyAgents 一致:损坏的 company.json 不参与核对
  354 + }
  355 +
  356 + const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : [];
  357 + const distinctRoles = new Set(roleNames); // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重
  358 + expectedNodesPerCompany[companyId] = distinctRoles.size;
  359 +
  360 + for (const roleName of distinctRoles) {
  361 + if (!knownIds.has(roleName)) {
  362 + expectedNewCompanyHiredIds.add(roleName);
  363 + }
  364 + }
  365 + }
  366 +
  367 + // ── DB 里的真实计数 ──
  368 + const bySourceKind = db.prepare(`
  369 + SELECT source_kind, COUNT(*) AS cnt FROM system_agents GROUP BY source_kind
  370 + `).all();
  371 + const actualCounts = { global_agent: 0, template: 0, company_hired: 0 };
  372 + for (const row of bySourceKind) {
  373 + actualCounts[row.source_kind] = row.cnt;
  374 + }
  375 +
  376 + const nodesByCompany = db.prepare(`
  377 + SELECT company_id, COUNT(*) AS cnt FROM org_graph_nodes GROUP BY company_id
  378 + `).all();
  379 + const actualNodesPerCompany = {};
  380 + for (const row of nodesByCompany) {
  381 + actualNodesPerCompany[row.company_id] = row.cnt;
  382 + }
  383 +
  384 + // ── 比对 ──
  385 + const mismatches = [];
  386 +
  387 + if (actualCounts.global_agent !== globalIds.size) {
  388 + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='global_agent'", expected: globalIds.size, actual: actualCounts.global_agent });
  389 + }
  390 + if (actualCounts.template !== expectedTemplateIds.size) {
  391 + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='template'", expected: expectedTemplateIds.size, actual: actualCounts.template });
  392 + }
  393 + if (actualCounts.company_hired !== expectedNewCompanyHiredIds.size) {
  394 + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='company_hired'", expected: expectedNewCompanyHiredIds.size, actual: actualCounts.company_hired });
  395 + }
  396 +
  397 + const allCompanyIds = new Set([...Object.keys(expectedNodesPerCompany), ...Object.keys(actualNodesPerCompany)]);
  398 + for (const companyId of allCompanyIds) {
  399 + const expected = expectedNodesPerCompany[companyId] || 0;
  400 + const actual = actualNodesPerCompany[companyId] || 0;
  401 + if (expected !== actual) {
  402 + mismatches.push({ check: `org_graph_nodes COUNT(*) WHERE company_id='${companyId}'`, expected, actual });
  403 + }
  404 + }
  405 +
  406 + return {
  407 + ok: mismatches.length === 0,
  408 + mismatches,
  409 + expected: {
  410 + global_agent: globalIds.size,
  411 + template: expectedTemplateIds.size,
  412 + company_hired: expectedNewCompanyHiredIds.size,
  413 + nodesPerCompany: expectedNodesPerCompany,
  414 + },
  415 + actual: {
  416 + global_agent: actualCounts.global_agent,
  417 + template: actualCounts.template,
  418 + company_hired: actualCounts.company_hired,
  419 + nodesPerCompany: actualNodesPerCompany,
  420 + },
  421 + };
  422 +}
  423 +
  424 +module.exports = {
  425 + seedGlobalAgents,
  426 + seedTemplates,
  427 + seedCompanyAgents,
  428 + verifyCounts,
  429 +};
server/tests/unit/graph-db.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — graph-db.js
  3 + * Asserts the three P0 tables exist and that GRAPH_DB_PATH env override
  4 + * actually redirects the underlying sqlite file (regression guard against
  5 + * the benchmark-db.js "declared but never read" bug).
  6 + */
  7 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  8 +import fs from 'fs';
  9 +import path from 'path';
  10 +import Database from 'better-sqlite3';
  11 +
  12 +describe('graph-db', () => {
  13 + let graphDb;
  14 + const TEST_DB_PATH = '/tmp/test-graph-db.sqlite';
  15 + const DEFAULT_DB_PATH = path.resolve(__dirname, '../../../data/graph.sqlite');
  16 +
  17 + beforeAll(() => {
  18 + if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
  19 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  20 + delete require.cache[require.resolve('../../services/graph-db')];
  21 + graphDb = require('../../services/graph-db');
  22 + });
  23 +
  24 + afterAll(() => {
  25 + try { fs.unlinkSync(TEST_DB_PATH); } catch {}
  26 + try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {}
  27 + try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {}
  28 + delete process.env.GRAPH_DB_PATH;
  29 + });
  30 +
  31 + it('should create all three tables', () => {
  32 + const d = graphDb.getDb();
  33 + const tables = d.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name);
  34 + expect(tables).toContain('system_agents');
  35 + expect(tables).toContain('org_graph_nodes');
  36 + expect(tables).toContain('org_graph_edges');
  37 + });
  38 +
  39 + it('should honor GRAPH_DB_PATH env override for reads/writes', () => {
  40 + const d = graphDb.getDb();
  41 + const now = new Date().toISOString();
  42 + d.prepare(`
  43 + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
  44 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  45 + `).run('test-agent-env-check', 'Test Agent', 'public', null, 'global_agent', 'system/agents/test-agent-env-check', 'active', null, now, now);
  46 +
  47 + // The row must have landed in the overridden file, not the default one.
  48 + expect(fs.existsSync(TEST_DB_PATH)).toBe(true);
  49 +
  50 + // Prove the override actually redirected the file: the default-path DB
  51 + // (opened independently, never touched by graph-db in this test run)
  52 + // must NOT contain the row we just wrote.
  53 + if (fs.existsSync(DEFAULT_DB_PATH)) {
  54 + const defaultDb = new Database(DEFAULT_DB_PATH, { readonly: true });
  55 + try {
  56 + const row = defaultDb.prepare('SELECT 1 FROM system_agents WHERE id = ?').get('test-agent-env-check');
  57 + expect(row).toBeUndefined();
  58 + } finally {
  59 + defaultDb.close();
  60 + }
  61 + } else {
  62 + // Default DB file was never created at all -- also proves the write
  63 + // went to the overridden path instead of the default one.
  64 + expect(fs.existsSync(DEFAULT_DB_PATH)).toBe(false);
  65 + }
  66 + });
  67 +});
server/tests/unit/graph-seed-service.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — graph-seed-service.js verifyCounts() (T4)
  3 + * Runs the real seed functions against the actual repo fs (system/agents/,
  4 + * system/templates/, data/companies/) into a throwaway GRAPH_DB_PATH, then
  5 + * asserts verifyCounts() reports ok:true against that freshly-seeded data,
  6 + * and that it actually catches a mismatch when the DB drifts from the fs.
  7 + */
  8 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  9 +import fs from 'fs';
  10 +
  11 +describe('graph-seed-service verifyCounts', () => {
  12 + let seedService;
  13 + let graphDb;
  14 + const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite';
  15 +
  16 + beforeAll(() => {
  17 + if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
  18 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  19 + delete require.cache[require.resolve('../../services/graph-db')];
  20 + delete require.cache[require.resolve('../../services/graph-seed-service')];
  21 + graphDb = require('../../services/graph-db');
  22 + seedService = require('../../services/graph-seed-service');
  23 + });
  24 +
  25 + afterAll(() => {
  26 + try { fs.unlinkSync(TEST_DB_PATH); } catch {}
  27 + try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {}
  28 + try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {}
  29 + delete process.env.GRAPH_DB_PATH;
  30 + });
  31 +
  32 + it('reports ok:true against a freshly-seeded DB (no drift)', () => {
  33 + seedService.seedGlobalAgents();
  34 + seedService.seedTemplates();
  35 + seedService.seedCompanyAgents();
  36 +
  37 + const v = seedService.verifyCounts();
  38 + expect(v.mismatches).toEqual([]);
  39 + expect(v.ok).toBe(true);
  40 + });
  41 +
  42 + it('catches a mismatch when the DB has a row the filesystem does not back', () => {
  43 + const db = graphDb.getDb();
  44 + const now = new Date().toISOString();
  45 + db.prepare(`
  46 + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
  47 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  48 + `).run('bogus-agent-no-fs-backing', 'Bogus', 'public', null, 'global_agent', 'system/agents/bogus-agent-no-fs-backing', 'active', null, now, now);
  49 +
  50 + const v = seedService.verifyCounts();
  51 + expect(v.ok).toBe(false);
  52 + expect(v.mismatches.some(m => m.check.includes("source_kind='global_agent'"))).toBe(true);
  53 +
  54 + db.prepare('DELETE FROM system_agents WHERE id = ?').run('bogus-agent-no-fs-backing');
  55 + });
  56 +});
tasks/plan.md 0 → 100644
  1 +# P0 地基 — Graph Agent 架构任务拆分
  2 +
  3 +> 对应 `docs/system-design/27-graph-agent-architecture.md` 的 Phase P0。目标:只加不改——新建三张表,把现有数据原样导入作为种子数据并校验行数,完全不动 `company-service.js` 现有的 hire/copy 执行路径。这是 P1(虚拟引用改造)能安全推进的前提:先有一份可信、经过校验的镜像数据在 DB 里,再去改代码。
  4 +
  5 +## Context
  6 +
  7 +两轮只读调研确认了关键事实:
  8 +
  9 +- 现有三个 sqlite 库(`skills.sqlite`/`benchmark_logs.sqlite`/`users.sqlite`)都遵循同一套手写约定:`server/services/*-db.js` 里 `new Database()` + WAL + `CREATE TABLE IF NOT EXISTS`(无迁移文件、无版本追踪),snake_case 列名。**没有 `:memory:` 测试,测试用临时文件 + env 变量覆盖 DB 路径 + 清 require 缓存**(`server/tests/unit/skill-db.test.js` 是标准范式)。`benchmark-db.js` 声明了 env override 但实际没读,是个已知的潜在 bug,新代码要避免重蹈。
  10 +- `system/agents/` 有 11 个目录,其中 `system/agents/templates/` 是遗留杂物、不是真 agent,必须过滤掉(真实 agent 数 = 10)。
  11 +- `system/templates/` 有 34 个目录,但 `_registry.json`(数组)只覆盖 9 个(`ceo`/`qa-judge`/`base`/6 个 devloop-*)——目录扫描才是数据源头,registry 只用来补充可选的 benchmark 元数据,其余 25 个模板要容忍没有 `config.json`(如 `ceo/` 只有 `plan.md`+`todo.md`)。
  12 +- `data/companies/` 目前只有一家真实公司 `idearun`,`company.json.agents` 是 6 个角色名字符串的扁平数组:`youtube-crawler`/`evolver-youtube-crawler` 能对上 `system/agents/` 里的全局 agent,另外 4 个(`claude-youtube-skills`/`trend-scout-global`/`content-selector`/`idearuncreate`)在 global/template 里都找不到对应目录——这些是公司专属训练出来的实例,是"company-scoped system agent"这个概念在真实数据里的第一批样本。
  13 +- `company-service.js` 的 `hire`(189)/`scaffoldNewAgent`(241)/`depositTemplate`(294)/`hireWithTraining`(381)/`_copyTemplate`(621)/`_injectCompanyContext`(667) 六个函数行号已核实无漂移,P0 全程不动这个文件。
  14 +
  15 +## 新增文件
  16 +
  17 +- `server/services/graph-db.js` —— 新 sqlite 库 `data/graph.sqlite`,照抄 `skill-db.js`/`user-db.js` 的样板:`fs.mkdirSync` 保证目录存在 → `new Database(DB_PATH)` → `journal_mode=WAL` + `foreign_keys=ON` → lazy singleton `getDb()`。**支持 `GRAPH_DB_PATH` env 覆盖,且要确保代码真的读了这个变量**。
  18 +- `server/services/graph-seed-service.js` —— 导出 `seedGlobalAgents()` / `seedTemplates()` / `seedCompanyAgents()` / `verifyCounts()`,供脚本和测试复用。
  19 +- `scripts/graph-seed.js` —— 薄 CLI 入口,依次调用上面四个函数并打印汇总报告,风格参照现有 `scripts/sync-skills.js`。
  20 +- `package.json` 加一条 `"graph:seed": "node scripts/graph-seed.js"`。
  21 +- `server/tests/unit/graph-db.test.js` —— 照抄 `skill-db.test.js` 的临时文件 + env 覆盖 + 清 require 缓存范式。
  22 +
  23 +## Schema
  24 +
  25 +```sql
  26 +CREATE TABLE IF NOT EXISTS system_agents (
  27 + id TEXT PRIMARY KEY, -- 就是目录名,如 'master'/'ceo'/'claude-youtube-skills'
  28 + name TEXT NOT NULL,
  29 + scope TEXT NOT NULL CHECK (scope IN ('public','company')),
  30 + owner_company_id TEXT,
  31 + source_kind TEXT NOT NULL CHECK (source_kind IN ('global_agent','template','company_hired')),
  32 + legacy_path TEXT NOT NULL, -- 当前真实所在目录,如 'system/agents/master'
  33 + status TEXT NOT NULL DEFAULT 'active',
  34 + benchmark_score REAL,
  35 + created_at TEXT NOT NULL,
  36 + updated_at TEXT NOT NULL
  37 +);
  38 +
  39 +CREATE TABLE IF NOT EXISTS org_graph_nodes (
  40 + id INTEGER PRIMARY KEY AUTOINCREMENT,
  41 + company_id TEXT NOT NULL,
  42 + system_agent_id TEXT NOT NULL REFERENCES system_agents(id),
  43 + role_name TEXT NOT NULL,
  44 + created_at TEXT NOT NULL
  45 +);
  46 +
  47 +CREATE TABLE IF NOT EXISTS org_graph_edges (
  48 + id INTEGER PRIMARY KEY AUTOINCREMENT,
  49 + company_id TEXT NOT NULL,
  50 + from_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id),
  51 + to_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id),
  52 + relation_type TEXT NOT NULL
  53 +);
  54 +```
  55 +
  56 +相对设计文档(27 号)草稿的两处调整:
  57 +
  58 +1. `system_agents.id` 用 **TEXT PRIMARY KEY = 目录名**,不用自增代理键——后续阶段要靠这个 id 反查文件系统路径,代理键只会多一层无意义的间接。
  59 +2. 新增 `source_kind` + `legacy_path` 两列——P0 明确不做目录搬迁(三种来源现在物理上分散在三个不同地方),没有这两列种子数据就"没有指针",没法在 P1 真正切换执行路径时定位到原文件。
  60 +
  61 +`org_graph_nodes`/`org_graph_edges` 沿用仓库现有的自增整数主键惯例,暂不需要对外暴露的字符串 ID(要到 P2/P3 才会被外部引用)。
  62 +
  63 +## 验证方式
  64 +
  65 +1. `npm run test:unit` —— 新增的 `graph-db.test.js` 通过,现有测试无回归
  66 +2. `npm run graph:seed` —— 在真实仓库数据上跑,人工核对输出的行数汇总(10 / 34 / 6,见下方任务列表)
  67 +3. `sqlite3 data/graph.sqlite ".schema"` —— 人工确认三张表结构与上面 SQL 一致
  68 +4. `git diff --stat` —— 确认这一阶段的改动只新增文件,没有改动 `company-service.js`/`agent-manager.js`/`prompt-builder.js` 任何一行
  69 +
  70 +任务清单见 [todo.md](./todo.md)。
tasks/todo.md 0 → 100644
  1 +# P0 任务清单
  2 +
  3 +依赖顺序:T0 阻塞全部 → T1/T2 → checkpoint → T3 → T4 → T5
  4 +
  5 +- [x] **T0 — 建表 + DB 模块**
  6 + - 产出:`server/services/graph-db.js` + `server/tests/unit/graph-db.test.js`
  7 + - 验收:测试断言三张表通过 `sqlite_master` 查询存在;断言设置 `GRAPH_DB_PATH` 后确实写到了别的文件(回归防 benchmark-db.js 那种"声明了但没读"的 bug)
  8 +
  9 +- [x] **T1 — 导入 `system/agents/` → `system_agents`**
  10 + - 扫描 `system/agents/*`,过滤掉没有 `agent.md` 的目录(排除 `templates/` 杂物),逐个插入 `scope='public', owner_company_id=NULL, source_kind='global_agent'`
  11 + - 验收:`SELECT COUNT(*) WHERE source_kind='global_agent'` = 10;脚本可重复执行不产生重复行(`INSERT OR IGNORE`/upsert on id)
  12 +
  13 +- [x] **T2 — 导入 `system/templates/` → `system_agents`**
  14 + - 扫描 34 个模板目录,用 `_registry.json` 按 id 匹配补充 `benchmark_score`(只有 9 个能匹配上,其余留 NULL),容忍缺失 `config.json`
  15 + - 验收:`COUNT(*) WHERE source_kind='template'` = 34;抽查 9 个 registry 覆盖到的条目 `benchmark_score` 非空,其余 25 个为 NULL
  16 + - **实际结果(见执行报告)**:真实目录是 39 个(非文档所写的 34),其中 6 个与 `system/agents/*` 目录同名(`agent-creator`/`digester`/`evolver`/`evolver-youtube-crawler`/`hunter`/`innovator`)——已加冲突保护跳过写入,避免污染 T1 的 global_agent 行,故 `COUNT(*) WHERE source_kind='template'` = 33;registry 9 个 id 全部匹配上,但其中 6 个 devloop-* 在 registry 里本身 `benchmarkScore` 就是 `null`,故非空的只有 `ceo`(97)/`qa-judge`(95)/`base`(90) 3 个,其余(含 6 个 devloop-* 匹配项)均为 NULL
  17 +
  18 +- [ ] **checkpoint** — T0-T2 落地后手动跑一遍 `graph:seed`,人工看一眼种子数据是否符合预期,再继续 T3
  19 +
  20 +- [x] **T3 — 导入 `data/companies/*/company.json.agents[]` → `system_agents` + `org_graph_nodes`**
  21 + - 已有同名 `system_agents` 行(来自 T1/T2)→ 只建 `org_graph_nodes` 指过去;没有 → 新建 `scope='company', owner_company_id=<companyId>, source_kind='company_hired'` 行,再建 `org_graph_nodes`。`org_graph_edges` 本阶段留空(`company.json` 目前没有汇报关系数据,是预期状态)
  22 + - 验收:对真实的 `idearun`,`org_graph_nodes` 恰好 6 行;其中 2 行引用已存在的行(`youtube-crawler`/`evolver-youtube-crawler`),4 行触发新建 `scope='company'` 记录
  23 + - **实际结果(见执行报告)**:`org_graph_nodes` 行数对得上(6 行),但引用/新建的拆分与本条原始预期不一致——亲自核对 `data/companies/idearun/company.json` 与 `system/agents/`、`system/templates/` 目录后发现:`idearun.agents[]` 的 6 个角色名(`claude-youtube-skills`/`youtube-crawler`/`evolver-youtube-crawler`/`trend-scout-global`/`content-selector`/`idearuncreate`)**全部**能在 T1/T2 已导入的 `system_agents` 里找到同名行(1 个 `source_kind='global_agent'`:`evolver-youtube-crawler`;5 个 `source_kind='template'`:`claude-youtube-skills`/`youtube-crawler`/`trend-scout-global`/`content-selector`/`idearuncreate`——这 5 个模板目录在 `system/templates/` 下真实存在,此前文档判断"在 global/template 里都找不到对应目录"是错的)。因此触发新建 `scope='company'` 行数 = **0**,不是预期的 4;`org_graph_nodes` 6 行全部引用已存在行。`youtube-crawler` 本身在 `system/agents/` 下也有同名目录,但该目录下没有 `agent.md`(只有 `.claude/`),T1 过滤规则将其排除,故它落在 `system_agents` 里的行是 T2 写入的 `template` 行,不是 `global_agent`。二次运行 `npm run graph:seed` 验证幂等:`org_graph_nodes` 新建 0 行、跳过 6 行,总数仍是 6。
  24 +
  25 +- [x] **T4 — 行数对账 + npm 脚本接线**
  26 + - `npm run graph:seed` 串联 T1→T2→T3;跑完打印按 `source_kind` 分组的行数汇总,独立于种子脚本内部计数器、重新扫描一次目录/文件做交叉核对
  27 + - 验收:连续跑两次 `npm run graph:seed` 行数不变(幂等);汇总报告三类 `source_kind` 数字都对得上 T1-T3 的真实数字
  28 + - **实际结果**:`verifyCounts()` 已加到 `server/services/graph-seed-service.js`,独立重新扫描 `system/agents/`(含 `agent.md` 过滤)、`system/templates/`(含跨源冲突规则)、`data/companies/*/company.json`(角色名去重),推导期望行数,再和 `SELECT source_kind, COUNT(*) ... GROUP BY source_kind` / `SELECT company_id, COUNT(*) FROM org_graph_nodes GROUP BY company_id` 的真实结果比对,不一致时返回 `ok:false` + `mismatches[]`。`scripts/graph-seed.js` 依次调用 `seedGlobalAgents → seedTemplates → seedCompanyAgents → verifyCounts`,打印汇总报告,`v.ok===false` 时 `process.exitCode = 1`。`package.json` 已有 `"graph:seed": "node scripts/graph-seed.js"`(此前会话已加)。连续跑两次 `npm run graph:seed`,真实数字:`global_agent=8`(`system/agents/` 现有 11 个目录,含 `agent.md` 的 8 个,文档里旧的"10"是过时数字,与本任务无关,不予改动)、`template=33`(39 个模板目录减去 6 个与 global_agent 同名被冲突保护跳过的)、`company_hired=0`(`idearun.agents[]` 6 个角色全部能对上已有行)、`org_graph_nodes[idearun]=6`。两次运行结果完全一致(幂等),第二次运行 `org_graph_nodes` 新建 0 行、跳过 6 行;直接查 SQLite 复核 `system_agents` 总行数 41(8+33)、`org_graph_nodes` 总行数 6、`org_graph_edges` 总行数 0,无重复行。另外人工注入一条无文件系统依据的假 `system_agents` 行验证 `verifyCounts()` 确实能检测出不一致(`ok:false`),且 `scripts/graph-seed.js` 在该场景下退出码为 1,验证了对账/退出码逻辑本身有效,不是摆设。
  29 +
  30 +- [x] **T5 — 确认零行为变更**
  31 + - 非编码任务:跑 `npm run test:unit` 全绿;`git diff --stat server/services/company-service.js` 应该没有任何改动
  32 + - 对应设计文档里 P0 的验收标准"现有 company-service.js 行为不变"
  33 + - **实际结果**:`npm run test:unit` 全绿(54 test files passed, 571 tests passed, 0 failed)。`git diff --stat server/services/company-service.js server/src/core/agent-manager.js server/src/core/prompt-builder.js`(working tree 和 `--cached` 两个都跑了)输出均为空字符串,退出码 0。额外核对:这三个文件最近一次被改动的提交是 `538d5d0`,早于本次 P0/devloop 全部提交(`83523ed`/`7335930`/`e1f71cf`/`bd1636b`/`3c14a4d`),确认整个 P0 阶段这三个文件零改动(含已提交和未提交两种情况)。