Commit 3bd46d05e3da38628deaf487f0c44ff416ae9871

Authored by yibo
1 parent 2b77f8c9

feat(p4): R-19 org 层契约接活;引擎提示词与模板口径对齐

第六次真 CLI 冒烟 gapClosed=true:org 层契约落库 4 行(此前恒 0),
且那段文字真的进了 workgraph-planning 收到的提示词(52677 字节,第 746 行起)。

## workflow 四路并行的产出

- **org 层两半一起接**(关键顺序):先接读回方 buildPlanningPrompt 的 ③bis 段
  (org 边入边 → 上游绝对路径;出边 produces → 「你规划的子图必须有节点产出这些」),
  再接声明方 orggraph-planning 模板。只补模板的话就是又一次「声明了没人读」——
  org 图节点全是 subgraph,而 describeInputs 对 subgraph 直接返回空。
  resolveInboundLines 从 describeInputs 抽出共用,两侧口径物理上不可能漂移。
- **清掉 404 死路径**:模板三处教的 POST /api/company/:id/work-graphs 从未注册。
  真跑复验:agent 侧零调用,改为按新 Step 7 写项目根绝对路径,还自己 require
  validateWorkGraph 预检了一遍才交回,round=1 一次过。
- **四张骨架补 produces/needs**:从「靠模板文字施压」变成结构保证,每张过规则 6。
  真跑里 agent 照抄了 planner.json 并跟着任务删掉 sdd 节点——是改编不是复制。
- **graph-seed 顺序依赖**:靠生产代码里已有的 GRAPH_SEED_ROOT 缝隙指向冻结快照。
  不是「跑几次都绿」结案:构造了原先必红的 churn,修前 12 次 3 红、修后 15 次 0 红。

## 我自己收的 4 条 major + 1 条 minor(都在 graph-engine.js)

复验挖出「引擎自己拼的提示词在教 agent 做模板明令禁止的事」,同一条提示词里对立:
1. 「已自行落库成功时回一行 workGraphId: wg_xxx」vs 模板「你永远拿不到真的 wg_ id,
   永远别写」。agent 没有任何受支持的落库途径,写出来必是编的,而 extractPlannedGraph
   优先匹配那个形状、查库查不到就整轮作废。删掉邀请,保留引擎解析真实 wg_ 的能力。
2. 内联图示例的边一个 produces 都没有——agent 唯一见过的「边长什么样」是零声明的。
3. 「端点尚未上线」暗示将来会上线,于是它还想去试一次。改成「不存在、也不会上线」。
4. indexNodeOutputs 收集 declared 不按 role 过滤,把出边的 needs 当成本节点的产出。
   Chrome 走查实测:产品需求文档.md 落成 artifact,下游 prd 输入变 ⚠️。
   更坏的一面是错标(brd_notes.md 命中中继 needs[brd] → 归因给错的产出方)。
   1ea9efa 加的 role 列,这里正是它存在的理由。
5. (minor) contract.prev.json(retryNode 的契约快照)被索引成 artifact 噪声。

新增 guard:引擎提示词不许与模板对立(静态读源码,剔掉注释行——
第一版把「解释为什么不许说某句话」的注释也扫进去了,量错了对象)。

测试 850 全绿(83 文件),既有断言未改。5 处变异精确变红后逐字还原。
Chrome 走查确认控制台正常分支(此前只验过降级分支):契约药丸、绝对路径、
needs 中继说明、tab 上的 ⚠️ 计数全部渲染,零 console 报错。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p4-parallel-and-artifacts.md
... ... @@ -342,8 +342,10 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts (
342 342 「声明要吃 X 但没人产 X」永远自洽 —— 那条校验会退化成一条永不报错的空跑规则。
343 343 `required:false` 是显式逃生口:产出方不在本图(`acceptance` 由上一层 org graph / `artifacts/` 给)时用它。
344 344  
345   -**尚未接通的一环**:`system/work-graph-templates/*.json` 四张骨架的边**还没有** `produces` 声明,
346   -所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。骨架补声明是下一步。
  345 +**~~尚未接通的一环~~(已接通,见下面第六次真跑)**:`system/work-graph-templates/*.json` 四张骨架的边
  346 +原先**没有** `produces` 声明,所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。
  347 +四张骨架现已补齐 `produces`/`needs`(backward 边一条都没动),第六次真跑实测规划 agent
  348 +会把骨架上的声明连同结构一起改编下来 —— 见「第六次真 CLI 冒烟」验收点⑤。
347 349  
348 350 #### 第五次真 CLI 冒烟:第四次那个断点复验通过(2026-08-17,未改一行代码)
349 351  
... ... @@ -373,6 +375,86 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts (
373 375 所以真跑后 `graph_edge_contracts` 里 `edge_layer='org'` **0 行**。
374 376 org 层这一半仍是「实现在、无真实调用方」。
375 377  
  378 +#### 第六次真 CLI 冒烟:org 层四环闭合(2026-08-17)
  379 +
  380 +**跑法**:worktree `wf_9fbefcf9-79e-5`,真 CLI 服务 3010(`data/` 是 worktree 自己的 ——
  381 +`company-service.js:18` 与 `graph-db.js:34` 都从 `__dirname/../../data` 派生,所以生产
  382 +`data/graph.sqlite` 结构上碰不到),`LEMONBOSS_DISABLE_WATCHDOG=1`(启动日志有
  383 +`[Watchdog] ⏸️ 已由 LEMONBOSS_DISABLE_WATCHDOG=1 关闭`)。公司 `smoke6`,org run
  384 +`run_5274dad6b10c` 手动模式,子 run `run_0691b85eb7fc`。
  385 +**被测版本 = `2b77f8c` + 工作树里未提交的四份改动**(org 层读回方 `describeOrgContracts`、
  386 +`orggraph-planning` 约束五、`workgraph-planning` 去死端点、四张骨架补 `produces`/`needs`)。
  387 +
  388 +**证据一律取 Claude CLI 自己的 transcript 原文**(`~/.claude/projects/<agent-cwd>/*.jsonl`
  389 +里第一条 user 消息 = 引擎真的发出去的那段字),不用 timeline 推断。
  390 +
  391 +**单测基线**:`npm run test:unit` 在 worktree 里 **837 passed / 6 skipped,83 个文件中 82 通过**。
  392 +唯一失败的 `server/tests/integration/graph-agent-p1-acceptance.test.js` 是**环境性失败、不是回归**:
  393 +它在 `:75` 断言 `data/companies/idearun/agents/evolver-youtube-crawler/agent.md` 存在,
  394 +而 `data/companies/` 被 `.gitignore` 忽略,**全新 worktree 里结构上不可能有这份 fixture**
  395 +(该断言是作者刻意加的「别让测试空跑通过」保险)。对账:主树基线 829 passed / 80 文件,
  396 +本轮新增三个测试文件共 14 条,失败套件里 6 条没跑 → `829 + 14 − 6 = 837`,与实测**逐数吻合**,
  397 +即既有用例零回归。
  398 +
  399 +| 验收点 | 结果 |
  400 +|---|---|
  401 +| **① org 层声明真的落库** | ✅ `graph_edge_contracts` 里 `edge_layer='org'` **4 行**(第五次真跑是 **0 行**)。边 1 `org-planner→org-coder`:`produces[prd]` + `produces[acceptance]`;边 2 `org-coder→org-validator`:`produces[artifact]` + **`needs[acceptance]`**。两条 backward 边(3/4)**零契约行** |
  402 +| **② org 契约进没进规划 agent 的提示词**(本轮核心) | ✅ **成立**。发给 `workgraph-planning` 的提示词(52 677 字节)第 746 行逐字出现 `## ③bis org-planner 在上一层图上的数据契约(引擎已解析,不要自己猜路径)`,其下「你这一层必须产出的东西(硬约束)」列出 `acceptance / prd` —— 正是 org 边 1 上声明的两个 kind。四环里的「读回」这一环闭合 |
  403 +| **③ 声明侧的语义质量** | ✅ 超出预期。`needs` 被用在**真正的隔节点中继**上:`org-validator` 要吃的 `acceptance` 产出方是 `org-planner`(非紧邻上游 `org-coder`),所以单独写 `needs`。规划记录 `workspace/orggraph-plan.md` 第 6 节「数据契约自查」表**填满了**,含 `data-contract` 规则预检、kind 取值域预检、以及「粒度:org 层比 work 层粗一档」自查 —— 约束五被完整吸收,不是照抄 |
  404 +| **④ 死端点** | ✅ agent **不再** curl `POST /api/company/:id/work-graphs`。全 transcript 里该字符串只出现在**提示词**里(两处,见下面的不对称②),agent 侧零调用。它按新 Step 7 把图写成项目根下的绝对路径 `projects/p-xdc09a/workgraph-T-msx3w8xm.json`(**没有**写 `workspace/` 那条与写入边界冲突的旧路径),并**自己 require 引擎的 `validateWorkGraph` 预检了一遍**才交回。落库一轮通过,`plan` 无 `rejected` |
  405 +| **⑤ 骨架 json 的声明起作用了吗** | ✅ 照抄并正确改编。宿主是 `org-planner` → 选 `planner.json`;任务是单文件 CLI 于是砍掉 `sdd` 节点,产出的四条 forward 边契约与骨架**同形**:`brd→prd produces[brd]`、`prd→benchmark produces[prd]`、`benchmark→validator produces[acceptance] + needs[brd,prd]`(骨架原文是 `needs[brd,prd,sdd]`,它跟着删掉了 `sdd` —— 说明是理解后改编,不是字面复制),三条 backward 边零声明。落库 `edge_layer='work'` **5 行** |
  406 +| **⑥ 回归:第五次那条已成立的链路** | ✅ **没被 `resolveInboundLines` 抽函数搞坏**。`org-planner-prd`(`prd-planning`)的真实提示词逐字复现第五次的形状:<br>`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`<br>`- brd:…/runs/run_5274dad6b10c/nodes/rn_9836aeb60827/brd.md`<br>`- contract(任务级共享):…/rn_9836aeb60827/contract.json`<br>**一个 ⚠️ 都没有**。行为侧也一致:prd agent 的头两个动作就是 `Read` 这两条绝对路径,随后判「BRD 完整且五问齐备,无需打回」,节点 **attempt 0 一次 success** |
  407 +| 产出侧 kind 认领 | ✅ `brd.md`→`kind=brd`、`prd.md`→`kind=prd`,两者都是**按出边 `produces` 声明**定的(不是文件名兜底);`contract.json`→`kind=contract` 按文件名 |
  408 +
  409 +**归因上的诚实交代**:⑤ 这一条无法把功劳单独归给 ③bis 还是骨架 —— `planner.json` 本身就在
  410 +`benchmark→validator` 上写了 `produces[acceptance]`、在 `prd→sdd` 上写了 `produces[prd]`,
  411 +所以「acceptance 与 prd 都有节点产出」这个结果两条路径都能推出来,本轮是**同向叠加**,
  412 +不构成对 ③bis 的独立验证。③bis 的独立证据只有验收点②(那段文字确实进了提示词)。
  413 +要把两者分开,得构造一个骨架覆盖不到的 kind(例如 org 边上声明 `produces[log]`)再看它补不补。
  414 +
  415 +**跑法上踩到的一个坑(我自己造成的,写下来免得下一轮再踩)**:
  416 +第一次跑 `org-planner-brd` 时节点被判 `fail / fail_reason=boundary_escape` 而进 `blocked`,
  417 +**而 agent 本身一个越界写入都没有**。`node.boundary` 事件的 `entries` 只有一条:
  418 +`M docs/todo/system-design/27-p4-parallel-and-artifacts.md` —— 那是**我在节点跑的同时编辑本文件**。
  419 +`applyBoundaryGuard`(`graph-engine.js:2829`)拍调用前后两次**全仓库** `git status`,
  420 +差集里的每一条都算当前节点的越界;`splitUnattributable`(:2807)的豁免名单只有
  421 +`system/agents/*/{todo.md,plan.md,memory.md,_lessons.md,.claude/scheduled_tasks.json}`
  422 +(`AGENT_RUNTIME_STATE_RE`),文档、源码、任何别的并发写入都不在豁免内。
  423 +**结论:真跑期间不要在同一个工作树里改任何被 git 跟踪的文件** —— 这条约束此前只写成
  424 +「关掉 Master 巡检」,但巡检只是并发写入的一种,人工编辑同样会冤枉节点。
  425 +(重试后 `attempt 1` 一次 success,所以这只是浪费一次调用,不影响上表任何结论;
  426 +但重试本身留下了一个副作用文件 `contract.prev.json`,它落成了 `kind=artifact` —— 见下面第 4 条。)
  427 +
  428 +**同轮暴露的四条不对称**(都不是本轮改动引入的,且都不在本轮可改文件内):
  429 +
  430 +1. **引擎提示词与新模板在 `wg_` 上直接对立,而且同处一条提示词里**。
  431 + `workgraph-planning/agent.md` 第 329/361 行(进了提示词)说「别自己编 `wg_` id」
  432 + 「**不许出现 `wg_` + 12 位十六进制**,引擎优先匹配这个形状并当成已落库的图 id,查不到就整轮作废」;
  433 + 而 `graph-engine.js:832` 拼出的第 775 行说「已经自行落库成功时,改为回一行 `workGraphId: wg_xxx`」。
  434 + 本轮 agent 选择了听模板(没写 `wg_`),但这是运气而非保证。修法:删掉 `graph-engine.js` 那一行
  435 + —— agent 永远不可能「自行落库成功」,那个分支对它来说只是陷阱。
  436 +2. **同一处还留着「端点尚未上线」的措辞**(`graph-engine.js:829` → 提示词第 770 行),
  437 + 暗示将来会上线;新模板的口径是 `NO-SUCH-ENDPOINT — 从来没有实现过`。两边措辞不一致。
  438 + 顺带一提,`graph-engine.js:832` 那个内联 JSON 示例的边是
  439 + `{"from":"...","to":"...","condition":"success"}` —— **一个 `produces` 都没有**,
  440 + 与模板「forward 边基本都要写 `produces`」正相反。三处(829/832/835)都在同一段字符串数组里,
  441 + 一次改完最省事。
  442 +3. **org 层 `produces` 在产出侧没有认领方**(已知上限,非回归):org 节点是 subgraph,
  443 + `deriveChildOutcome` 不回 `outputRef` → `output_ref` 恒 `null` → `indexNodeOutputs` 第一行就 return。
  444 + org 边上的 `produces` 能不能真的查到实例,完全取决于**子 run 里的 work 节点**把文件登记成对的 kind
  445 + (`graph_files` 按 `(company_id, task_id)` 检索,子 run 继承同一个 `task_id`)。
  446 + 本轮 org run 只推进到第一个 org node,**没有**验到第二个 org node 的入边把路径查出来 ——
  447 + ③bis 的「上游节点交给你的输入」那一半(`resolveInboundLines` 拼绝对路径)在真跑里**仍未被覆盖**,
  448 + 下一次真跑应当专门推到第二个 org node。
  449 + *间接旁证*:`resolveInboundLines` 是 `describeInputs` 与 `describeOrgContracts` **共用的同一个函数**
  450 + (`graph-engine.js:3226`),验收点⑥已经证明它在真跑里能拼出正确绝对路径;
  451 + org 层缺的只是「这条 org 入边上确实有声明 + 索引里确实有实例」这个具体组合的现场,不是代码路径。
  452 +4. **`contract.prev.json` 会被当成产物索引进去**(`kind=artifact`)。它是 `retryNode` 为算契约 hash
  453 + 留的快照(`retry` 响应里的 `contractSnapshot.file`),不是节点交付物。产出侧没把它排除,
  454 + 于是每次人工重试都会给 `graph_files` 添一行噪声。`validator.json` 那条「刻意零 produces」的
  455 + 注释里已经写过同类道理(`attempt-{n}.json` 被排除在索引外),`contract.prev.json` 漏了。
  456 + 修法:`indexNodeOutputs` 的排除名单加上它。
  457 +
376 458 #### 控制台读回端(R19-C:R-4「边的输入输出」的字面落地)
377 459  
378 460 此前 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 第 2 个 tab 把 R-4 落成了
... ...
server/services/graph-engine.js
... ... @@ -801,6 +801,10 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = &#39;&#39; }
801 801 '',
802 802 '## ③ 可读的 artifacts/(root run 共享产物区,只读)',
803 803 describeArtifactsDir(ctx.run),
  804 + // R-19 org 层契约的读回方。返回 null(没有任何契约行)时**整段连标题一起消失**,
  805 + // 靠下面那个 filter 滤掉 —— 编号用 ③bis 而不是插进 ①②③④,那四项是模板 Step 1
  806 + // 「四项输入一项都不能跳」的原文清单,重编号会让模板与提示词对不上。
  807 + describeOrgContracts(ctx),
804 808 '',
805 809 '## ④ 上一轮 remediation_note',
806 810 ctx.remediationNote || '无,首次规划。',
... ... @@ -822,13 +826,21 @@ function buildPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = &#39;&#39; }
822 826 `- 节点引用的 systemAgentId 必须是**已存在的本体**;不存在的本体会被引擎以 rule=reference 打回。`,
823 827 '',
824 828 '## 交回方式(引擎读你回复的最后一段)',
825   - '`POST /api/company/{公司ID}/work-graphs` 端点尚未上线,走模板 Step 6 的降级路径:',
  829 + // 措辞必须与 workgraph-planning/agent.md 的 NO-SUCH-ENDPOINT 一致:说「尚未上线」会被读成
  830 + // 「将来会上线」,agent 就还会去试一次、吃一个 404、白烧一轮(前五次真跑每轮都在烧)。
  831 + // 这个端点从来没有设计过,落库始终是引擎的活。
  832 + '`POST /api/company/{公司ID}/work-graphs` **不存在、也不会上线** —— 落库是引擎的活:',
826 833 '**把最终图直接贴在回复里的一个 ```json 代码块中**(或写成一个 .json 文件后给出它的绝对路径),形状:',
827 834 '```json',
  835 + // 示例的边必须带 produces:这段是 agent 唯一见过的「一条边长什么样」,
  836 + // 示例里没有声明,它照抄出来的就是一张不传数据的图 —— 引擎自己的示例在教反面教材。
828 837 '{"nodes":[{"nodeName":"...","systemAgentId":"...","nodeKind":"agent_call"}],'
829   - + '"edges":[{"from":"...","to":"...","condition":"success"}]}',
  838 + + '"edges":[{"from":"...","to":"...","condition":"success","produces":["brd"]}]}',
830 839 '```',
831   - '已经自行落库成功时,改为回一行 `workGraphId: wg_xxx`。',
  840 + // 刻意**不**再提「已落库时回一行 workGraphId」:没有任何受支持的落库途径,
  841 + // 所以这句话只会诱发它编一个 wg_ id;而 extractPlannedGraph 优先匹配那个形状、
  842 + // 查库查不到就整轮作废(模板 agent.md:345 把这条列成硬禁令,两边对立时 agent 得赌)。
  843 + // 引擎侧仍保留认真实 wg_ id 的分支,只是不再邀请它写。
832 844 ];
833 845  
834 846 if (round > 1) {
... ... @@ -891,6 +903,86 @@ function describeArtifactsDir(run) {
891 903 return out.join('\n');
892 904 }
893 905  
  906 +/** 本节点**出边**上声明为 produces 的 kind(去重)。role 为 NULL 的历史行按 produces 读,
  907 + * 与 org-graph-service.readGraph 回读契约时同一个口径。backward 出边不算:引擎解析输入
  908 + * 只看 forward 入边,写在回退边上的声明本就不生效(validateGraph 也拦了)。 */
  909 +function outboundProducedKinds(layer, edges, defNodeId) {
  910 + const out = [];
  911 + for (const e of edges || []) {
  912 + if (String(e.from) !== String(defNodeId)) continue;
  913 + if ((e.edgeKind || 'forward') === 'backward') continue;
  914 + for (const c of edgeContracts(layer, e.id)) {
  915 + if ((c.role || 'produces') !== 'needs') out.push(c.kind);
  916 + }
  917 + }
  918 + return [...new Set(out)];
  919 +}
  920 +
  921 +/**
  922 + * R-19 org 层:把 org 图上的数据契约递给 **workgraph-planning**(这就是 org 层契约的读回方)。
  923 + *
  924 + * 为什么不能靠 describeInputs:它第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串,
  925 + * 而 readDefGraph 把 org 图的节点**全部**写成 nodeKind='subgraph'(work-graph-db.js
  926 + * 「org graph 的节点全部是 subgraph executor」)。两条合起来 = org 层的边契约在输入侧
  927 + * 一个读回方都没有。先补模板去教 orggraph-planning 声明,产出的就是「声明了没人读」。
  928 + *
  929 + * 语义也不该复用 describeInputs 的措辞。org 节点自己不干活,它派给规划本体的是**约束**:
  930 + * 入边声明的 kind → 你规划的子图的**入口节点**要吃这些(引擎已经把绝对路径查好)
  931 + * 出边声明的 kind → 你规划的子图**必须有节点产出**这些,否则下游 org node 拿不到输入
  932 + * 这才是两层递归里 org 层约束 work 层的方式。
  933 + *
  934 + * 拼不出来(没有契约行 / def 读不出来)时返回 **null**,整段连标题一起省略:
  935 + * 留一个空标题会被 agent 读成「引擎查过了,确实没有」,而真相是「引擎没查」。
  936 + *
  937 + * ponytail: 这里自己再读一次 def(defIndex)而不是让 advanceOne 把 def 塞进 ctx ——
  938 + * 代价是每一轮规划多一次定义层查询(org 图是个位数节点,可忽略),换来的是 ctx 形状不变:
  939 + * ctx 会原样传给 setAgentExecutor 注入的外部实现,往里塞一个含 Map 的字段是更大的面。
  940 + * 规划轮数一多(PLAN_MAX_ROUNDS=3)也就三次;真要省,把 def 提到 executeSubgraph 里传参。
  941 + */
  942 +function describeOrgContracts(ctx) {
  943 + if (!ctx || !ctx.run) return null;
  944 + let inboundLines = [];
  945 + let produced = [];
  946 + try {
  947 + const layer = ctx.run.graph_kind === 'org' ? 'org' : 'work';
  948 + const edges = defIndex(ctx.run).edges || [];
  949 + inboundLines = resolveInboundLines(ctx, inboundContracts(layer, edges, ctx.defNodeId));
  950 + produced = outboundProducedKinds(layer, edges, ctx.defNodeId);
  951 + } catch {
  952 + return null; // 索引/定义层读不出来不该拖垮规划调用
  953 + }
  954 + if (!inboundLines.length && !produced.length) return null;
  955 +
  956 + const lines = ['', `## ③bis ${ctx.nodeName} 在上一层图上的数据契约(引擎已解析,不要自己猜路径)`];
  957 + if (inboundLines.length) {
  958 + lines.push(
  959 + '',
  960 + '### 上游节点交给你的输入',
  961 + ...inboundLines,
  962 + '标 ⚠️ 的表示上一层图上声明了这份输入、但产物索引里还没有实例:先确认是不是上游还没跑完,',
  963 + '不要自己去别的节点私有目录里翻。',
  964 + '**这些路径要落到你规划的子图的入口节点手上** —— 引擎不会自动把它们拼进那个节点的提示词,',
  965 + '你要写进入口节点的任务描述里。',
  966 + );
  967 + }
  968 + if (produced.length) {
  969 + lines.push(
  970 + '',
  971 + '### 你这一层必须产出的东西(硬约束)',
  972 + `上一层图上你的出边声明了这些 kind,下游节点就是按它们去产物索引里找输入的:${produced.join(' / ')}`,
  973 + '**你规划的子图里必须有节点真的产出上面每一个 kind**,一个都不能少。',
  974 + '少一个的后果不是现在报错,而是下游节点跑到那一步说「我拿不到输入」—— 那时整个子图的预算已经烧掉了。',
  975 + '落地方式(两条都要做到位才算产出):',
  976 + ` ① 在产出它的那条 work 边上写 \`"produces":["${produced[0]}"]\`(引擎按出边声明给文件定 kind);`,
  977 + // 出口节点没有出边 → 它的 indexNodeOutputs 拿不到任何声明,只能按文件名认 kind。
  978 + // 这条不写清楚的话,org 层最后一个 kind 恰好就是最容易掉的那个。
  979 + ' ② 出口节点没有出边、写不了 produces 时,把**文件名**直接起成 kind 本身'
  980 + + `(引擎只看去掉扩展名的那一段,认得出的是 ${KNOWN_FILE_KINDS.join(' / ')})。`,
  981 + );
  982 + }
  983 + return lines.join('\n');
  984 +}
  985 +
894 986 /**
895 987 * 从 agent 的回复里取出图。三种形态按优先级:
896 988 * ① `workGraphId: wg_xxx`(它自己 POST 成功了)
... ... @@ -3070,7 +3162,17 @@ function indexNodeOutputs(run, def, nodeRun) {
3070 3162 }
3071 3163 if (!files.length) return;
3072 3164  
3073   - // 出边声明的 kind:优先用它给文件定 kind
  3165 + // 出边声明的 kind:优先用它给文件定 kind。
  3166 + //
  3167 + // **只收 role='produces'**:出边上的 needs 说的是「下游要吃这个 kind,而产出方不是紧邻上游」
  3168 + // (中继声明,写在消费方入边上 —— 那与本节点的出边是同一条边),它不是本节点的产出。
  3169 + // 混进来会坏两件事:
  3170 + // ① 认领作废:p-prd 的出边写 produces[prd]+needs[brd] → 未认领 kind 数 2 ≠ 1 →
  3171 + // 下面那条「一对一无歧义」的兜底不成立,文件退成 artifact,下游查不到实例。
  3172 + // ② 更坏的错标:文件名里带 brd(如 brd_notes.md)会命中中继的 needs[brd],
  3173 + // 把它错标成 brd —— 错的 kind 让下游读到一份不是它要的文件,比 artifact 查不到更坏。
  3174 + // role 列是随 needs/produces 一起落库的(work-graph-db 的 graph_edge_contracts),
  3175 + // 这里正是它存在的理由;旧库迁移行回填成 produces,所以缺省当 produces 处理。
3074 3176 let declared = [];
3075 3177 try {
3076 3178 const layer = run.graph_kind === 'org' ? 'org' : 'work';
... ... @@ -3078,7 +3180,9 @@ function indexNodeOutputs(run, def, nodeRun) {
3078 3180 (e) => String(e.from) === String(nodeRun.def_node_id) && (e.edgeKind || 'forward') !== 'backward'
3079 3181 );
3080 3182 for (const e of outbound) {
3081   - for (const c of edgeContracts(layer, e.id)) declared.push(c.kind);
  3183 + for (const c of edgeContracts(layer, e.id)) {
  3184 + if ((c.role || 'produces') !== 'needs') declared.push(c.kind);
  3185 + }
3082 3186 }
3083 3187 declared = [...new Set(declared)];
3084 3188 } catch { /* 契约查不到就只靠文件名 */ }
... ... @@ -3086,8 +3190,13 @@ function indexNodeOutputs(run, def, nodeRun) {
3086 3190 const companyRoot = companyRootDir(run.company_id);
3087 3191  
3088 3192 // ── 第一遍:按文件名认领 kind
3089   - // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物
3090   - const candidates = files.filter((f) => !/^attempt-\d+\.json$/.test(f));
  3193 + // 引擎自己的簿记文件不是节点交付物,不进索引:
  3194 + // attempt-*.json 归因文件(readNodeProduct 读它)
  3195 + // contract.prev.json retryNode 为算 contract_hash 留的上一轮快照(CONTRACT_SNAPSHOT_FILE)
  3196 + // 漏掉后者的话,每次人工重试都给 graph_files 添一行 kind=artifact 的噪声。
  3197 + const candidates = files.filter(
  3198 + (f) => !/^attempt-\d+\.json$/.test(f) && f !== CONTRACT_SNAPSHOT_FILE
  3199 + );
3091 3200 const assigned = candidates.map((f) => {
3092 3201 const base = f.replace(/\.[^.]+$/, '').toLowerCase();
3093 3202 // 声明优先:出边说会产 brd,且文件名里就有 brd → 定 brd
... ... @@ -3132,6 +3241,31 @@ function indexNodeOutputs(run, def, nodeRun) {
3132 3241 }
3133 3242  
3134 3243 /**
  3244 + * 入边声明的 kind → 产物索引里的绝对路径,一个 kind 一行。
  3245 + *
  3246 + * 抽出来是为了让**执行侧**(describeInputs,agent_call 节点)与**规划侧**
  3247 + * (describeOrgContracts,subgraph 节点交给 workgraph-planning 的那份提示词)
  3248 + * 走同一段代码:三种口径(查得到给绝对路径 / required 查不到给 ⚠️ /
  3249 + * 可选查不到整行省略)就不可能在两处漂移 —— 复制一遍再各自演化正是本轮在防的那类断链。
  3250 + */
  3251 +function resolveInboundLines(ctx, contracts) {
  3252 + const declared = [...new Set(contracts.map((c) => c.kind))];
  3253 + if (!declared.length) return [];
  3254 + const found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: declared });
  3255 + const byKind = new Map();
  3256 + for (const f of found) byKind.set(f.kind, f);
  3257 + const root = companyRootDir(ctx.companyId);
  3258 + const lines = [];
  3259 + for (const kind of declared) {
  3260 + const hit = byKind.get(kind);
  3261 + const required = contracts.some((c) => c.kind === kind && c.required);
  3262 + if (hit) lines.push(`- ${kind}:${path.join(root, hit.rel_path)}`);
  3263 + else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`);
  3264 + }
  3265 + return lines;
  3266 +}
  3267 +
  3268 +/**
3135 3269 * 「这个节点需要什么输入、在哪」—— 两类数据流都查(R-19.3 定案①):
3136 3270 * ① 沿边传递:入边上声明的 kind(受拓扑相邻性限制)
3137 3271 * ② 任务级共享:本 task 下的 acceptance / contract 这类,**不受相邻性限制**
... ... @@ -3158,20 +3292,8 @@ function describeInputs(ctx, def) {
3158 3292 }
3159 3293  
3160 3294 const declared = [...new Set(contracts.map((c) => c.kind))];
3161   - const lines = [];
  3295 + const lines = resolveInboundLines(ctx, contracts);
3162 3296  
3163   - if (declared.length) {
3164   - const found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: declared });
3165   - const byKind = new Map();
3166   - for (const f of found) byKind.set(f.kind, f);
3167   - const root = companyRootDir(ctx.companyId);
3168   - for (const kind of declared) {
3169   - const hit = byKind.get(kind);
3170   - const required = contracts.some((c) => c.kind === kind && c.required);
3171   - if (hit) lines.push(`- ${kind}:${path.join(root, hit.rel_path)}`);
3172   - else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`);
3173   - }
3174   - }
3175 3297 // 任务级共享按 kind 去重,只列最新的那一份(findFiles 是 created_at 升序,所以后写的覆盖前写的
3176 3298 // —— 与上面沿边那一半的 byKind.set 同一个口径)。
3177 3299 //
... ...
server/tests/unit/graph-files-index.test.js
... ... @@ -427,4 +427,82 @@ describe(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; {
427 427 expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path));
428 428 expect(listed[0]).not.toContain(rows[0].rel_path);
429 429 });
  430 +
  431 + // ── ⑨ 出边上的 needs 不算本节点的产出 ──────────────────────────
  432 + //
  433 + // Chrome 走查里实测到的:p-prd 的出边写 produces['prd'] + needs['brd'](中继声明),
  434 + // 收集「本节点产出哪些 kind」时若不按 role 过滤,未认领 kind 数就是 2 而不是 1,
  435 + // ⑦ 那条一对一认领当场失效 —— 文件退成 artifact、下游拿不到。
  436 +
  437 + it('⑨ 出边同时有 produces 与 needs:认领只看 produces,中继的 needs 不参与', async () => {
  438 + const companyId = 'co-files-role';
  439 + const taskId = 'task-role';
  440 + const b = buildRun({
  441 + companyId, taskId,
  442 + nodes: [{ nodeName: 'w-brd' }, { nodeName: 'w-prd' }, { nodeName: 'w-val' }],
  443 + edges: [
  444 + { from: 'w-brd', to: 'w-prd', condition: 'success' },
  445 + { from: 'w-prd', to: 'w-val', condition: 'success' },
  446 + ],
  447 + });
  448 + // w-prd 的出边:它产 prd;同时声明 w-val 还要吃 brd(产出方是 w-brd,不是紧邻上游)
  449 + const e2 = b.edgeId('w-prd', 'w-val');
  450 + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'prd', role: 'produces' });
  451 + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'brd', role: 'needs' });
  452 +
  453 + await advance(b.runId, 2, {
  454 + writes: {
  455 + // 文件名认不出 kind → 只能靠声明认领;needs 混进来的话这里会退成 artifact
  456 + 'w-prd': { '产品需求文档.md': '# 产品需求' },
  457 + },
  458 + });
  459 +
  460 + const rows = allFiles(companyId);
  461 + expect(rows).toHaveLength(1);
  462 + expect(rows[0].kind).toBe('prd');
  463 + });
  464 +
  465 + it('⑨ 出边只有 needs 时不认领(本节点什么都没声明要产)', async () => {
  466 + const companyId = 'co-files-only-needs';
  467 + const taskId = 'task-only-needs';
  468 + const b = buildRun({
  469 + companyId, taskId,
  470 + nodes: [{ nodeName: 'n-a' }, { nodeName: 'n-b' }],
  471 + edges: [{ from: 'n-a', to: 'n-b', condition: 'success' }],
  472 + });
  473 + wdb.declareEdgeContract({
  474 + edgeLayer: 'work', edgeId: b.edgeId('n-a', 'n-b'), kind: 'sdd', role: 'needs',
  475 + });
  476 +
  477 + await advance(b.runId, 1, { writes: { 'n-a': { '随便写的.md': 'x' } } });
  478 +
  479 + // needs 不是产出声明,不该被拿来认领 —— 认领了就是把「下游要吃 sdd」
  480 + // 误读成「我产 sdd」,下游读到一份不是它要的文件
  481 + const rows = allFiles(companyId);
  482 + expect(rows).toHaveLength(1);
  483 + expect(rows[0].kind).toBe('artifact');
  484 + });
  485 +
  486 + // ── ⑩ 引擎自己的簿记文件不进索引 ────────────────────────────────
  487 +
  488 + it('⑩ contract.prev.json 不进索引(retryNode 的契约快照,不是节点交付物)', async () => {
  489 + const companyId = 'co-files-snapshot';
  490 + const taskId = 'task-snapshot';
  491 + const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 's-brd' }] });
  492 +
  493 + await advance(b.runId, 1, {
  494 + writes: {
  495 + 's-brd': {
  496 + 'brd.md': '# BRD',
  497 + // 引擎为算 contract_hash 留的上一轮快照。漏掉它的话,
  498 + // 每次人工重试都往 graph_files 添一行 kind=artifact 的噪声
  499 + 'contract.prev.json': JSON.stringify({ outputs: [] }),
  500 + 'attempt-1.json': JSON.stringify({ fail_reason: null }),
  501 + },
  502 + },
  503 + });
  504 +
  505 + const rows = allFiles(companyId);
  506 + expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']);
  507 + });
430 508 });
... ...
server/tests/unit/graph-seed-service.test.js
... ... @@ -12,6 +12,8 @@
12 12 * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实
13 13 * `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok,
14 14 * 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。
  15 + * 读的仍然是真实仓库的内容,但 `data/companies/` 走一次**冻结快照**(见 freezeRepoRoot)——
  16 + * 原因写在那个函数上。
15 17 */
16 18 import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
17 19 import fs from 'fs';
... ... @@ -33,6 +35,70 @@ function write(file, content) {
33 35 fs.writeFileSync(file, content);
34 36 }
35 37  
  38 +const REPO_ROOT = path.resolve(__dirname, '../../..');
  39 +
  40 +/**
  41 + * 把「真实仓库根」冻成一个不会在用例中途变形的 GRAPH_SEED_ROOT。
  42 + *
  43 + * 为什么需要冻:verifyCounts() 的价值在于**独立重扫一遍目录**再跟库里的行数对,
  44 + * 于是 seedCompanyAgents() 那次扫描和 verifyCounts() 那次扫描之间只要磁盘变了一次,
  45 + * 期望值和实际值就必然不等 —— 而 `data/companies/` 是运行期状态,同一轮 `vitest run` 里
  46 + * 至少有 company-hire-org-layer.test.js / graph-agent-p1-{hire,acceptance}.test.js
  47 + * 三个文件在真实 `data/companies/` 下建公司再 hire()(company.json 的 agents[] 逐个变长),
  48 + * afterAll 又整目录删掉;用户开着 dev server 时还会有人从 UI 建公司。
  49 + * 实测:拿一个反复建/删的公司目录撞这个窗口,12 次单跑里 3 次红在
  50 + * `org_graph_nodes COUNT(*) WHERE company_id=...`。这就是它在全套里偶发变红的全部原因。
  51 + *
  52 + * 冻的是「一致性」而不是「新鲜度」:快照本身可能抓到某家公司写一半的样子,
  53 + * 但两次扫描读的是同一份,结论照旧成立。所以断言一条没放松 —— 真实仓库里多一个
  54 + * 模板目录 / 多一家公司带着 seeder 会跳过的角色,照样红(下面有验证)。
  55 + *
  56 + * `system/` 不快照,直接软链:它是签入代码,跑测试期间没人往里加目录
  57 + * (hire() 只 bump `_registry.json` 里的 usedCount,改内容不建目录,而 _registry.json
  58 + * 只影响 benchmark_score,不影响任何行数)。软链还顺带让
  59 + * 「every system/templates/* dir …」那条用例仍然对着真实目录清单比。
  60 + */
  61 +function freezeRepoRoot(dest) {
  62 + fs.mkdirSync(dest, { recursive: true });
  63 + fs.symlinkSync(path.join(REPO_ROOT, 'system'), path.join(dest, 'system'));
  64 +
  65 + const src = path.join(REPO_ROOT, 'data', 'companies');
  66 + const dst = path.join(dest, 'data', 'companies');
  67 + if (!fs.existsSync(src)) return; // 连目录都没有 → 快照里也不该有,别把安全阀的判定条件改掉
  68 + fs.mkdirSync(dst, { recursive: true });
  69 +
  70 + for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
  71 + if (!entry.isDirectory()) continue;
  72 + try {
  73 + // 没有 company.json → seedCompanyAgents 与 verifyCounts 都跳过它,不必进快照
  74 + const companyJson = fs.readFileSync(path.join(src, entry.name, 'company.json'));
  75 + write(path.join(dst, entry.name, 'company.json'), companyJson);
  76 +
  77 + let roles = [];
  78 + try {
  79 + const parsed = JSON.parse(companyJson);
  80 + if (Array.isArray(parsed.agents)) roles = parsed.agents;
  81 + } catch {
  82 + // 坏掉的 company.json:两个实现都 continue,快照里留着原样即可
  83 + }
  84 + for (const role of roles) {
  85 + const roleDir = path.join(src, entry.name, 'agents', role);
  86 + if (!fs.existsSync(roleDir)) continue; // legacy_path 存在性也要一并复制
  87 + fs.mkdirSync(path.join(dst, entry.name, 'agents', role), { recursive: true });
  88 + // isMetaBootstrap() 只看这一个文件,而它决定 seedCompanyAgents 建不建 org 节点 —— 必须带上
  89 + try {
  90 + fs.copyFileSync(
  91 + path.join(roleDir, 'config.json'),
  92 + path.join(dst, entry.name, 'agents', role, 'config.json')
  93 + );
  94 + } catch {}
  95 + }
  96 + } catch {
  97 + // 这家公司正被别的测试 / dev server 删掉 → 跳过。快照要的是自洽,不是全集。
  98 + }
  99 + }
  100 +}
  101 +
36 102 describe('graph-seed-service (hermetic fixture root)', () => {
37 103 let root;
38 104 let dbPath;
... ... @@ -271,20 +337,22 @@ describe(&#39;graph-seed-service (hermetic fixture root)&#39;, () =&gt; {
271 337 describe('graph-seed-service verifyCounts (real repo root)', () => {
272 338 let seedService;
273 339 let graphDb;
274   - const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite';
  340 + let snapshotRoot;
275 341  
276 342 beforeAll(() => {
277   - if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
278   - delete process.env.GRAPH_SEED_ROOT; // 真实仓库根
279   - process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  343 + // mkdtemp 而不是固定 /tmp/test-graph-seed-service.sqlite:同一台机器上并行跑两遍
  344 + // 这个文件时不该共用一个库
  345 + snapshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-realroot-'));
  346 + freezeRepoRoot(snapshotRoot);
  347 + process.env.GRAPH_SEED_ROOT = snapshotRoot;
  348 + process.env.GRAPH_DB_PATH = path.join(snapshotRoot, 'graph.sqlite');
280 349 ({ graphDb, seedService } = reloadServices());
281 350 });
282 351  
283 352 afterAll(() => {
284   - try { fs.unlinkSync(TEST_DB_PATH); } catch {}
285   - try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {}
286   - try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {}
  353 + delete process.env.GRAPH_SEED_ROOT;
287 354 delete process.env.GRAPH_DB_PATH;
  355 + try { fs.rmSync(snapshotRoot, { recursive: true, force: true }); } catch {}
288 356 });
289 357  
290 358 it('reports ok:true against a freshly-seeded DB (no drift)', () => {
... ...
server/tests/unit/org-layer-contracts.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — R-19 **org 层**数据契约:先有读回方,才谈声明方
  3 + *
  4 + * 起因是第五次真 CLI 冒烟:`graph_edge_contracts` 里 `edge_layer='org'` 是 0 行。
  5 + * 直觉判断是「orggraph-planning 模板没教它声明」,核实之后这个判断是**错的**:
  6 + *
  7 + * - work-graph-db.readDefGraph 给 org 图的节点**全部**写 nodeKind='subgraph'
  8 + * (注释原文:「org graph 的节点全部是 subgraph executor」);
  9 + * - graph-engine.describeInputs 第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串。
  10 + *
  11 + * 两条合起来 = org 层的边契约在输入侧**一个读回方都没有**。先补模板的话,产出的正是
  12 + * 「声明了没人读」——本仓库已经踩了 14 次的那个形状。
  13 + *
  14 + * 所以读回方接在 `buildPlanningPrompt`(org 节点派给 workgraph-planning 的提示词)上:
  15 + * org 层契约的语义正落在这里 —— 「你这个 org node 要吃上游产出的 X、要产下游要吃的 Y,
  16 + * 于是你规划的子图必须让入口节点消费 X、必须有节点产出 Y」。
  17 + *
  18 + * 本文件锁四件事:
  19 + * ① 读回方成立 org 边上的 produces → 规划提示词里出现 ③bis 段 + 上游实例的**绝对路径**
  20 + * ② 三种口径 查得到给绝对路径 / required 查不到给 ⚠️ / 可选查不到整行省略
  21 + * ③ 无契约不留空标题 一条契约都没有时整段(含标题)消失,不给 agent「查过了,没有」的错觉
  22 + * ④ 模板防漂移 orggraph-planning 模板里出现的每个「边上声明键」都必须被
  23 + * org-graph-service.normalizeContracts 真读到;kind 取值域逐字对齐 ARTIFACT_KINDS
  24 + *
  25 + * buildPlanningPrompt / describeOrgContracts **故意不导出**(graph-engine.js 的口径:
  26 + * 不为测试新开公开面),所以 ①②③ 全部走 advanceOne 这个真入口:
  27 + * 用 setPlanWorkGraph 只替掉**上游** org 节点的规划(给它一张现成子图),
  28 + * 被测节点则委托回真的 `defaultPlanWorkGraph`,靠替换 AgentManager 截获它发出的提示词。
  29 + *
  30 + * 隔离手法同 graph-files-index.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
  31 + * 仓库 data/ 全程不被碰;Claude CLI 一次都不起(AgentManager 被换成假货)。
  32 + */
  33 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  34 +import fs from 'fs';
  35 +import os from 'os';
  36 +import path from 'path';
  37 +
  38 +const REPO = path.resolve(__dirname, '../../..');
  39 +
  40 +describe('R-19 org 层数据契约(读回方 = buildPlanningPrompt)', () => {
  41 + let wdb;
  42 + let orgSvc;
  43 + let engine;
  44 + let runsDir;
  45 + const TEST_DB_PATH = '/tmp/test-org-layer-contracts.sqlite';
  46 + const AM_PATH = require.resolve('../../src/core/agent-manager');
  47 +
  48 + /** defaultPlanWorkGraph 发出的提示词,一次调用一条 */
  49 + let captured = [];
  50 + let savedAgentManager;
  51 +
  52 + /**
  53 + * 假 AgentManager:截下提示词就抛。
  54 + *
  55 + * 抛而不是「回一张合法子图」是刻意的:本文件要验的是**提示词内容**,
  56 + * 让规划"成功"还得替它造一张过五项校验的子图,那些代码与被测行为无关。
  57 + * defaultPlanWorkGraph 对 callAgent 抛出的错不重试(只对「图画错了」重试),
  58 + * 所以一次 advanceOne 恰好产出一条提示词,节点按 node_error 失败 —— 正是我们要的。
  59 + */
  60 + class FakeAgentManager {
  61 + async callAgent(agentName, prompt) {
  62 + captured.push({ agentName, prompt });
  63 + throw new Error('fake-agent-manager: 已截获提示词,不真的调 CLI');
  64 + }
  65 + }
  66 +
  67 + beforeAll(() => {
  68 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  69 + try { fs.unlinkSync(f); } catch {}
  70 + }
  71 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  72 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'org-contracts-runs-'));
  73 + process.env.GRAPH_RUNS_DIR = runsDir;
  74 + for (const m of [
  75 + '../../services/graph-db',
  76 + '../../services/graph-service',
  77 + '../../services/org-graph-service',
  78 + '../../services/work-graph-db',
  79 + '../../services/graph-run-sse',
  80 + '../../services/graph-engine',
  81 + ]) {
  82 + delete require.cache[require.resolve(m)];
  83 + }
  84 + wdb = require('../../services/work-graph-db');
  85 + orgSvc = require('../../services/org-graph-service');
  86 + engine = require('../../services/graph-engine');
  87 +
  88 + // defaultPlanWorkGraph 里是 `require('../src/core/agent-manager')`(调用时才 require),
  89 + // 所以换掉 require.cache 里的 exports 就够,不必去 patch 原型 —— 也就不会构造真的
  90 + // AgentManager(它的构造函数会挂上全局 LogManager / SessionPool)。afterAll 原样还回去。
  91 + savedAgentManager = require.cache[AM_PATH];
  92 + require.cache[AM_PATH] = {
  93 + id: AM_PATH, filename: AM_PATH, loaded: true, children: [], paths: [],
  94 + exports: FakeAgentManager,
  95 + };
  96 + });
  97 +
  98 + afterAll(() => {
  99 + if (savedAgentManager) require.cache[AM_PATH] = savedAgentManager;
  100 + else delete require.cache[AM_PATH];
  101 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  102 + try { fs.unlinkSync(f); } catch {}
  103 + }
  104 + delete process.env.GRAPH_DB_PATH;
  105 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  106 + delete process.env.GRAPH_RUNS_DIR;
  107 + for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
  108 + delete require.cache[require.resolve(m)];
  109 + }
  110 + });
  111 +
  112 + // ── 脚手架 ───────────────────────────────────────────────────
  113 +
  114 + let seq = 0;
  115 + /** 前缀刻意带 tmp-:resolvePlanningAgentName 会拿它去 REPO/data/companies 下找目录,
  116 + * 找不到就试着 hire 一次;hire 第一步 get(companyId) 读不到 company.json 直接抛,
  117 + * 被 catch 掉 —— 全程不在仓库 data/ 下建任何东西。 */
  118 + const nextCompany = () => `tmp-orgc-${process.pid}-${++seq}`;
  119 +
  120 + function ensureAgents(ids) {
  121 + const db = wdb.getDb();
  122 + const ts = new Date().toISOString();
  123 + const ins = db.prepare(`
  124 + INSERT OR IGNORE INTO system_agents
  125 + (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
  126 + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
  127 + `);
  128 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts);
  129 + }
  130 +
  131 + /**
  132 + * 起一张 org run,把入口节点跑通(它的子图写出 `writes` 里的文件并被索引),
  133 + * 让第二个 org 节点变 ready,然后再推一步 —— 那一步就会调真的 defaultPlanWorkGraph。
  134 + *
  135 + * @param edges org 边,可带 produces / needs
  136 + * @param writes 入口节点的子图那个 work 节点要落盘的文件 {文件名: 内容}
  137 + * @returns {Promise<{prompt: string, runId: string}>} 被测节点(org-coder)收到的规划提示词
  138 + */
  139 + async function planOrgRun({ companyId, taskId, nodes, edges, writes = {} }) {
  140 + expect(orgSvc.writeGraph(companyId, { nodes, edges }).ok).toBe(true);
  141 +
  142 + ensureAgents(['plan-worker']);
  143 + const sub = wdb.createWorkGraph({ companyId, taskId, nodes: ['plan-worker'], edges: [] });
  144 + expect(sub.errors || []).toEqual([]);
  145 +
  146 + // 上游 org 节点用注入的假规划(一张现成子图);被测节点委托回真路径。
  147 + engine.setPlanWorkGraph(async (ctx) => {
  148 + if (ctx.nodeName === 'org-planner') return { workGraphId: sub.id };
  149 + return engine.defaultPlanWorkGraph(ctx);
  150 + });
  151 + // 子图里那个 work 节点:把文件写进自己的私有目录,outputRef 指向该目录
  152 + // —— 形状与 defaultAgentExecutor 一致,settleNode 的 indexNodeOutputs 才会登记它。
  153 + engine.setAgentExecutor(async (ctx) => {
  154 + const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId);
  155 + fs.mkdirSync(dir, { recursive: true });
  156 + for (const [name, body] of Object.entries(writes)) {
  157 + fs.writeFileSync(path.join(dir, name), body);
  158 + }
  159 + return { status: 'success', outputRef: dir };
  160 + });
  161 +
  162 + let runId;
  163 + try {
  164 + const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, taskId });
  165 + runId = run.id;
  166 + engine.markRunReady(run.id);
  167 + engine.startRun(run.id, { mode: 'manual' });
  168 +
  169 + captured = [];
  170 + await engine.advanceOne(run.id); // org-planner → 起子 run
  171 + const child = wdb.listRunTree(run.id).find((r) => r.parent_run_id === run.id);
  172 + await engine.advanceOne(child.id); // 子图那个节点跑完 → 回写父节点
  173 + expect(wdb.getRun(child.id).run_status).toBe('completed');
  174 + await engine.advanceOne(run.id); // org-coder → 真 defaultPlanWorkGraph
  175 + } finally {
  176 + engine.setPlanWorkGraph(null);
  177 + engine.setAgentExecutor(null);
  178 + }
  179 +
  180 + expect(captured).toHaveLength(1);
  181 + return { prompt: captured[0].prompt, runId };
  182 + }
  183 +
  184 + // ── ① 读回方成立(最关键的一条)───────────────────────────────
  185 +
  186 + it('① org 边上声明 produces → 被测 org node 的规划提示词里出现 ③bis 段与上游实例的绝对路径', async () => {
  187 + const companyId = nextCompany();
  188 + const taskId = 'task-org-in';
  189 + const { prompt } = await planOrgRun({
  190 + companyId, taskId,
  191 + nodes: ['org-planner', 'org-coder', 'org-validator'],
  192 + edges: [
  193 + // 入边:上游 org node 会产出一份 brd 交给 org-coder
  194 + { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] },
  195 + // 出边:org-coder 自己要产出一份 sdd 给下游 org node;
  196 + // 同一条边上的 needs 是**下游**要吃的(产出方是隔了一个节点的 org-planner),
  197 + // 它不是 org-coder 的产出 —— 混进去就会命令 org-coder 再产一份 brd。
  198 + { from: 'org-coder', to: 'org-validator', condition: 'success', produces: ['sdd'], needs: ['brd'] },
  199 + ],
  200 + writes: { 'brd.md': '# 业务需求' },
  201 + });
  202 +
  203 + // 提示词确实发给了规划本体(不是发给别人)
  204 + expect(captured[0].agentName).toMatch(/workgraph-planning$/);
  205 +
  206 + // 入边那一半:给的是**绝对路径**,且指向真实落盘的那份 brd.md
  207 + expect(prompt).toContain('③bis');
  208 + expect(prompt).toContain('### 上游节点交给你的输入');
  209 + const abs = prompt.match(/^- brd:(.+)$/m);
  210 + expect(abs, '提示词里没有 brd 那一行').toBeTruthy();
  211 + expect(path.isAbsolute(abs[1])).toBe(true);
  212 + // 真正的证据不是「长得像路径」,而是这个路径**读得出上游刚写的那份内容**。
  213 + // (rel_path 是相对公司根算的,节点私有目录在 GRAPH_RUNS_DIR 覆盖下位于公司根之外,
  214 + // 所以拼回来的绝对路径带 ../ 归一后并不以公司根开头 —— 不拿前缀做断言。)
  215 + expect(fs.readFileSync(abs[1], 'utf-8')).toBe('# 业务需求');
  216 + expect(prompt).not.toContain('⚠️ 上游声明会产出');
  217 +
  218 + // 出边那一半:硬约束语气 + 点名 kind
  219 + expect(prompt).toContain('### 你这一层必须产出的东西(硬约束)');
  220 + expect(prompt).toMatch(/必须有节点真的产出上面每一个 kind/);
  221 + // 只列出边上 role='produces' 的那些。同一条出边上的 needs(brd)是下游要吃的东西,
  222 + // 混进来就变成「命令 org-coder 再产一份 brd」——一条它根本没答应过的硬约束。
  223 + const produced = prompt.match(/找输入的:(.+)$/m);
  224 + expect(produced, '提示词里没有「必须产出」那一行').toBeTruthy();
  225 + expect(produced[1]).toBe('sdd');
  226 + // 出边声明的 kind 不该被误当成「上游给你的输入」列在入边那一半里
  227 + expect(prompt).not.toMatch(/^- sdd:/m);
  228 + });
  229 +
  230 + // ── ② 另两种口径:required 查不到给 ⚠️;可选查不到整行省略 ──────
  231 +
  232 + it('② required 声明查不到实例 → ⚠️;required:false 查不到 → 整行省略(不留半句话)', async () => {
  233 + const companyId = nextCompany();
  234 + const taskId = 'task-org-miss';
  235 + const { prompt } = await planOrgRun({
  236 + companyId, taskId,
  237 + nodes: ['org-planner', 'org-coder'],
  238 + edges: [{
  239 + from: 'org-planner', to: 'org-coder', condition: 'success',
  240 + // brd 会真的落盘;acceptance 是 required 但没人产;log 是可选且没人产
  241 + produces: ['brd', 'acceptance', { kind: 'log', required: false }],
  242 + }],
  243 + writes: { 'brd.md': '# 业务需求' },
  244 + });
  245 +
  246 + expect(prompt).toMatch(/^- brd:\//m);
  247 + expect(prompt).toContain('- acceptance:⚠️ 上游声明会产出,但索引里查不到实例');
  248 + // 可选且查不到 → 一行都不留(留一行「查不到」会让 agent 以为这是个待办)
  249 + expect(prompt).not.toMatch(/^- log[::]/m);
  250 + });
  251 +
  252 + // ── ③ 一条契约都没有时整段消失(含标题)──────────────────────
  253 +
  254 + it('③ org 图上一条契约都没声明 → ③bis 整段不出现(空标题会被读成「查过了,没有」)', async () => {
  255 + const companyId = nextCompany();
  256 + const { prompt } = await planOrgRun({
  257 + companyId, taskId: 'task-org-none',
  258 + nodes: ['org-planner', 'org-coder'],
  259 + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }],
  260 + writes: { 'brd.md': '# 业务需求' }, // 实例在索引里,但图上没人声明要它
  261 + });
  262 +
  263 + expect(prompt).not.toContain('③bis');
  264 + expect(prompt).not.toContain('上游节点交给你的输入');
  265 + expect(prompt).not.toContain('必须产出的东西');
  266 + // 其余四项输入照旧在(证明省略的只是这一段,不是整段提示词拼挂了)
  267 + expect(prompt).toContain('## ③ 可读的 artifacts/');
  268 + expect(prompt).toContain('## ④ 上一轮 remediation_note');
  269 + });
  270 +
  271 + // ── ⑤ 产出侧的事实:org 节点自己什么都不登记 ────────────────────
  272 +
  273 + it('⑤ org 层的 produces 在产出侧不靠 indexNodeOutputs:org 节点 output_ref 恒为 null,实例是子 run 里的 work 节点登记的', async () => {
  274 + const companyId = nextCompany();
  275 + const taskId = 'task-org-out';
  276 + const { runId } = await planOrgRun({
  277 + companyId, taskId,
  278 + nodes: ['org-planner', 'org-coder'],
  279 + edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }],
  280 + writes: { 'brd.md': '# 业务需求' },
  281 + });
  282 +
  283 + // org 节点是 subgraph executor,它的 outcome 由 deriveChildOutcome 拼,**不带 outputRef**。
  284 + // 所以 indexNodeOutputs 在 org run 上第一行就 return —— 3076 行那句 layer 三元里的
  285 + // 'org' 分支对 org run 是走不到的死路径。这不是缺陷,是「org 节点自己不写盘」的必然结果。
  286 + const orgNodes = wdb.getDb()
  287 + .prepare(`SELECT * FROM graph_run_nodes WHERE run_id = ?`).all(runId);
  288 + expect(orgNodes.length).toBeGreaterThan(0);
  289 + expect(orgNodes.map((n) => n.output_ref)).toEqual(orgNodes.map(() => null));
  290 +
  291 + // 那份 brd 的索引行是**子 run 里那个 work 节点**登记的(node_run_id 属于子 run)。
  292 + // 它之所以能被 org 层的入边声明查到,只因为 graph_files 是按 (company, task) 检索的,
  293 + // 而子 run 从父 run 继承同一个 task_id —— 这就是 org 层 produces 在产出侧成立的全部理由。
  294 + const rows = wdb.findFiles({ companyId, taskId, kinds: ['brd'] });
  295 + expect(rows).toHaveLength(1);
  296 + expect(orgNodes.map((n) => n.id)).not.toContain(rows[0].produced_by_node_run_id);
  297 + const child = wdb.listRunTree(runId).find((r) => r.parent_run_id === runId);
  298 + expect(rows[0].produced_by_run_id).toBe(child.id);
  299 + });
  300 +
  301 + // ── ④ 模板防漂移:教的键名 / 取值域必须是落库侧真读的那个 ──────
  302 +
  303 + it('④ orggraph-planning 模板里的边上声明键,normalizeContracts 必须真的认(差一个字母就是静默失效)', () => {
  304 + const dir = path.join(REPO, 'system/templates/orggraph-planning');
  305 + const text = fs.readdirSync(dir)
  306 + .filter((f) => f.endsWith('.md'))
  307 + .map((f) => fs.readFileSync(path.join(dir, f), 'utf-8'))
  308 + .join('\n');
  309 +
  310 + // 两个键都得教到:少 produces 就没人产,少 needs 就表达不了非相邻依赖
  311 + expect(text).toMatch(/"produces"/);
  312 + expect(text).toMatch(/"needs"/);
  313 +
  314 + const keys = [...new Set(
  315 + [...text.matchAll(/"(produces|needs|carries|consumes|inputs|outputs|provides)"/g)].map((m) => m[1])
  316 + )];
  317 + expect(keys.length).toBeGreaterThan(0);
  318 + for (const key of keys) {
  319 + const contracts = orgSvc.normalizeContracts({ from: 'x', to: 'y', [key]: ['brd'] });
  320 + expect(contracts.map((c) => c.kind), `模板教了 "${key}",但归一函数不读这个键`).toEqual(['brd']);
  321 + }
  322 +
  323 + // kind 取值域逐字对齐:模板漏教一个值,规划 agent 就永远不会用它;
  324 + // 模板多教一个值,validateGraph 会以 rule=schema 把整张图打回。
  325 + for (const kind of orgSvc.ARTIFACT_KINDS) {
  326 + expect(text, `ARTIFACT_KINDS 里的 ${kind} 模板没教`).toContain(kind);
  327 + }
  328 + // backward 边上不许声明 —— 引擎解析输入只看 forward 入边(validateGraph 也拦)
  329 + expect(text).toMatch(/backward/);
  330 + });
  331 +});
... ...
server/tests/unit/work-graph-skeletons.test.js 0 → 100644
  1 +/**
  2 + * system/work-graph-templates/*.json 的**数据契约**防线(R-19 / 27-P4 §4.6)。
  3 + *
  4 + * 挡的是这一类缺陷:骨架的 forward 边只带控制流(condition / edgeKind / failReason),
  5 + * 不带 produces / needs。照抄骨架得到的图于是**不传数据** —— 下游节点的提示词里
  6 + * 一条上游产物路径都没有,第四次真 CLI 冒烟里 org-planner-prd 的原话就是
  7 + * 「不是上游没产出,是它的产出结构上到不了我手上」。
  8 + * 补上声明前,那件事只能靠 workgraph-planning 的提示词施压来避免;换个模型就漏。
  9 + *
  10 + * 与 template-refs.test.js 的分工:那边管**引用**(systemAgentId 指不指得到真本体、
  11 + * 落不落得进库),这边管**数据流**(边上有没有声明、声明落库后读不读得回来)。
  12 + *
  13 + * 四条用例分别对应四环里的一环:
  14 + * ① 骨架过引擎真正用的那套校验(含规则 6 data-contract:声明要吃的 kind 必须有产出方)
  15 + * ② 每张骨架的**每条** forward 边都有非空声明(新加一张骨架漏写声明也会被抓)
  16 + * ③ 声明落库:createWorkGraph → graph_edge_contracts 真有行,且 role/kind/required 与 JSON 一致
  17 + * ④ 读回:inboundContracts(describeInputs 的数据源)对每个非入口节点都非空
  18 + */
  19 +import { describe, it, expect, beforeAll } from 'vitest';
  20 +import fs from 'fs';
  21 +import os from 'os';
  22 +import path from 'path';
  23 +
  24 +const ROOT = path.resolve(__dirname, '../../..');
  25 +const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates');
  26 +
  27 +/**
  28 + * 占位符 → 真实存在的本体 id / 节点名。
  29 + * 落库那两条用例必须替换:work_graph_nodes.system_agent_id 有外键指向 system_agents(id),
  30 + * `{{implAgent}}` 会直接 FOREIGN KEY constraint failed —— 那样测的就不是契约而是外键了。
  31 + * 取值只要求「system/templates/ 下真实存在」,语义上贴近即可。
  32 + */
  33 +const PLACEHOLDERS = {
  34 + '{{orgNode}}': 'org-coder',
  35 + '{{splitAgent}}': 'devloop-architect',
  36 + '{{implAgent}}': 'devloop-coder',
  37 + '{{testAgent}}': 'devloop-tester',
  38 + '{{mergeAgent}}': 'devloop-integrator',
  39 + '{{integrationAgent}}': 'devloop-tester',
  40 + '{{validatorAgent}}': 'coder-validator',
  41 +};
  42 +
  43 +function readSkeletons() {
  44 + return fs
  45 + .readdirSync(WORK_GRAPH_DIR)
  46 + .filter((f) => f.endsWith('.json'))
  47 + .map((f) => ({
  48 + file: f,
  49 + json: JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_DIR, f), 'utf-8')),
  50 + }));
  51 +}
  52 +
  53 +/** 把骨架里的 {{占位符}} 全部替换成真实 id;替换后仍残留 {{ 就让它炸,不要静默落一张废图 */
  54 +function materialize(skeleton) {
  55 + let text = JSON.stringify(skeleton.json);
  56 + for (const [ph, real] of Object.entries(PLACEHOLDERS)) text = text.split(ph).join(real);
  57 + if (text.includes('{{')) throw new Error(`${skeleton.file} 有未覆盖的占位符,请补 PLACEHOLDERS`);
  58 + return JSON.parse(text);
  59 +}
  60 +
  61 +const isForward = (e) => (e.edgeKind || e.edge_kind || 'forward') !== 'backward';
  62 +
  63 +let wdb;
  64 +let orgGraphService;
  65 +let normalizeContracts;
  66 +
  67 +beforeAll(async () => {
  68 + // 隔离手法与 template-refs.test.js / graph-engine.test.js 一致:先设 env 再 import。
  69 + // 仓库的 data/graph.sqlite 一行都不该被本文件碰到。
  70 + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'work-graph-skeletons-'));
  71 + process.env.GRAPH_DB_PATH = path.join(tmp, 'graph.sqlite');
  72 + process.env.GRAPH_RUNS_DIR = path.join(tmp, 'runs');
  73 +
  74 + const graphDb = await import('../../services/graph-db.js');
  75 + const seed = await import('../../services/graph-seed-service.js');
  76 + wdb = (await import('../../services/work-graph-db.js')).default;
  77 + orgGraphService = (await import('../../services/org-graph-service.js')).default;
  78 + normalizeContracts = orgGraphService.normalizeContracts;
  79 +
  80 + graphDb.default.getDb();
  81 + seed.default.seedTemplates(); // system/templates/* → system_agents(外键的另一端)
  82 +});
  83 +
  84 +describe('work graph 骨架的数据契约', () => {
  85 + const skeletons = readSkeletons();
  86 +
  87 + it('本目录下有骨架可查(防止目录改名让整个文件静默空跑)', () => {
  88 + expect(skeletons.map((s) => s.file).sort()).toEqual([
  89 + 'executor-fanout.json',
  90 + 'executor.json',
  91 + 'planner.json',
  92 + 'validator.json',
  93 + ]);
  94 + });
  95 +
  96 + it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方)', () => {
  97 + const verdicts = skeletons.map((s) => {
  98 + const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges);
  99 + return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`;
  100 + });
  101 + expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]);
  102 + });
  103 +
  104 + it('每张骨架的每条 forward 边都有非空的 produces / needs 声明', () => {
  105 + // 「至少一张有」不够:新增骨架时漏写声明必须当场红。
  106 + // backward 边反过来一律不许声明(validateGraph 会报 rule=schema),上一条用例已覆盖。
  107 + const naked = [];
  108 + for (const s of skeletons) {
  109 + for (const e of s.json.edges || []) {
  110 + if (!isForward(e)) continue;
  111 + if (normalizeContracts(e).length === 0) naked.push(`${s.file}: ${e.from} -> ${e.to}`);
  112 + }
  113 + }
  114 + expect(naked).toEqual([]);
  115 + });
  116 +
  117 + it('声明真的落库:createWorkGraph 之后 graph_edge_contracts 里 kind/role/required 与 JSON 逐条一致', () => {
  118 + // 这一步把「JSON 里写了」和「落库后读得到」分开:前者只是文本,后者才是四环闭合的第三环。
  119 + const mismatches = [];
  120 + let contractRows = 0;
  121 +
  122 + for (const s of skeletons) {
  123 + const json = materialize(s);
  124 + const created = wdb.createWorkGraph({
  125 + companyId: 'work-graph-skeletons-test',
  126 + taskId: `T-${s.json.id}`,
  127 + nodes: json.nodes,
  128 + edges: json.edges,
  129 + });
  130 + expect(`${s.file}: ${created.ok ? 'ok' : JSON.stringify(created.errors)}`).toBe(`${s.file}: ok`);
  131 +
  132 + const def = wdb.readDefGraph('work', created.id);
  133 + const nameOf = new Map(def.nodes.map((n) => [n.defNodeId, n.name]));
  134 + // 按端点名配对,**不能**按声明序:readDefGraph 是 ORDER BY created_at, id,
  135 + // 同一次事务里 created_at 全等,实际排序落到随机 id 上,落库序 ≠ 声明序。
  136 + const dbByEndpoints = new Map(
  137 + def.edges
  138 + .filter((e) => e.edgeKind !== 'backward')
  139 + .map((e) => [`${nameOf.get(e.from)} -> ${nameOf.get(e.to)}`, e])
  140 + );
  141 + const jsonEdges = json.edges.filter(isForward);
  142 + expect(dbByEndpoints.size).toBe(jsonEdges.length);
  143 +
  144 + for (const je of jsonEdges) {
  145 + const label = `${s.file} ${je.from} -> ${je.to}`;
  146 + const dbEdge = dbByEndpoints.get(`${je.from} -> ${je.to}`);
  147 + if (!dbEdge) {
  148 + mismatches.push(`${label}: 落库后找不到这条边`);
  149 + continue;
  150 + }
  151 + const want = normalizeContracts(je)
  152 + .map((c) => `${c.role}:${c.kind}:${c.required}`)
  153 + .sort();
  154 + const got = wdb
  155 + .edgeContracts('work', dbEdge.id)
  156 + .map((r) => `${r.role}:${r.kind}:${r.required === 1}`)
  157 + .sort();
  158 + contractRows += got.length;
  159 + if (JSON.stringify(want) !== JSON.stringify(got)) {
  160 + mismatches.push(`${label}: JSON=${JSON.stringify(want)} DB=${JSON.stringify(got)}`);
  161 + }
  162 + }
  163 + }
  164 +
  165 + expect(mismatches).toEqual([]);
  166 + expect(contractRows).toBeGreaterThan(0); // 全都对不上却"两边都空"时不许静默通过
  167 + });
  168 +
  169 + it('读回:每个非入口节点的 inboundContracts 都非空(describeInputs 拼路径的数据源)', () => {
  170 + // 引擎给下游拼「上游输入」段落走的就是这个函数(graph-engine.describeInputs)。
  171 + // 它对某个节点返回空 = 那个节点的提示词里一条上游路径都没有。
  172 + const starved = [];
  173 +
  174 + for (const s of skeletons) {
  175 + const json = materialize(s);
  176 + const created = wdb.createWorkGraph({
  177 + companyId: 'work-graph-skeletons-test',
  178 + taskId: `T-inbound-${s.json.id}`,
  179 + nodes: json.nodes,
  180 + edges: json.edges,
  181 + });
  182 + expect(created.ok).toBe(true);
  183 +
  184 + const def = wdb.readDefGraph('work', created.id);
  185 + const hasForwardIn = new Set(
  186 + def.edges.filter((e) => e.edgeKind !== 'backward').map((e) => String(e.to))
  187 + );
  188 + for (const n of def.nodes) {
  189 + if (!hasForwardIn.has(String(n.defNodeId))) continue; // 入口节点没有入边,输入来自提示词
  190 + if (wdb.inboundContracts('work', def.edges, n.defNodeId).length === 0) {
  191 + starved.push(`${s.file}: ${n.name}`);
  192 + }
  193 + }
  194 + // 每张图至少有一个非入口节点,否则这条用例在"没节点可查"的情况下静默通过
  195 + expect(hasForwardIn.size).toBeGreaterThan(0);
  196 + }
  197 +
  198 + expect(starved).toEqual([]);
  199 + });
  200 +});
... ...
server/tests/unit/workgraph-planning-endpoints.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — workgraph-planning 模板的防漂移闸:**模板教的端点必须真的存在**
  3 + *
  4 + * 挡的是已经实际发生过的这一类缺陷:模板(`system/templates/workgraph-planning/*.md`)
  5 + * 教 agent `POST /api/company/<公司ID>/work-graphs` 去落库,而这个路由**从来没有实现过**
  6 + * (`server/index.js` 只挂了 `/api/graph-runs` 与 `/api/company` 的 org-graph 系列,
  7 + * 设计文档里也查不到这个端点)。后果不是功能坏掉 —— `graph-engine.js` 的
  8 + * `extractPlannedGraph` 那条「读回复取图」的分支本来就是主路径,图照样落得进去 ——
  9 + * 而是**每次真跑都白烧一轮 agent 调用**去 curl 一个必然 404 的地址(第五次真 CLI 冒烟实测)。
  10 + * 这种缺陷在单测里天然无感:prompt 文本没有任何编译期检查,端点没了也不会有人红。
  11 + *
  12 + * 所以这里把「模板文本」和「真实路由表」对起来查:
  13 + * ① 模板里出现的每个 `/api/...` 路径,都必须能在 `server/routes/` 里找到注册;
  14 + * ② 反向:被显式标注为反例(`NO-SUCH-ENDPOINT`)的路径,必须**确实不存在** ——
  15 + * 哪天这个端点真上线了,这条会红,提醒把警示文本删掉,而不是留一句过时的谣言;
  16 + * ③ 模板教 agent 交回的那份图示例,本身要是**严格 JSON** 且过 `validateWorkGraph`:
  17 + * 引擎用 `JSON.parse` 取图,示例里带注释/尾逗号就等于教它白烧一轮。
  18 + *
  19 + * ── 路由表怎么来 ────────────────────────────────────────────────
  20 + * **静态解析** `server/index.js` 的 `app.use('<前缀>', require('./routes/<文件>')[.<导出>])`
  21 + * + 路由文件里的 `<router>.get/post/...('<路径>')`,不 require 任何路由模块:
  22 + * 那些模块一 require 就会拉起 service 层(sqlite 连接等),而这条 guard 只需要文本事实。
  23 + *
  24 + * ponytail: 路径匹配按「段」严格比:占位符段(`:id` / `<公司ID>` / `{公司ID}`)两侧都归一成
  25 + * `*`,且**只与占位符相等** —— 字面量位置上出现占位符也算不匹配(松一点就会出现
  26 + * `/api/company/{公司ID}/work-graphs` 被 `PATCH /api/company/templates/:templateId` 匹配上,
  27 + * 实测过)。上限:模板把一族端点写成 `{start,next,...}` 时靠展开逗号处理,
  28 + * 而**动词(GET/POST)不参与比对** —— 端点存在但方法写错,这条查不出来。
  29 + * 要更紧就得真起 express app 逐条 match,代价是把 service 层全拉起来,不值当。
  30 + */
  31 +import { describe, it, expect } from 'vitest';
  32 +import fs from 'fs';
  33 +import path from 'path';
  34 +
  35 +const ROOT = path.resolve(__dirname, '../../..');
  36 +const TEMPLATE_DIR = path.join(ROOT, 'system/templates/workgraph-planning');
  37 +const ROUTES_DIR = path.join(ROOT, 'server/routes');
  38 +const INDEX_JS = path.join(ROOT, 'server/index.js');
  39 +
  40 +/**
  41 + * 反例标记:同一行里出现它,这一行的端点就**不要求**注册(反而要求它不存在,见用例 ②)。
  42 + * 选「同一行」而不是「同一段落」是为了让判据肉眼可查 —— 加一句无关的警示文字不会顺手
  43 + * 豁免掉隔壁那条真端点。
  44 + */
  45 +const NO_SUCH = 'NO-SUCH-ENDPOINT';
  46 +
  47 +/**
  48 + * `{start,next,...}` 这种「一族端点写成一行」的写法展开成逐条候选(`...` 省略号丢掉)。
  49 + * 只展开**带逗号**的花括号 —— `{公司ID}` 是占位符,不是选择集。
  50 + */
  51 +function expandAlternatives(p) {
  52 + const m = /\{([^{}]*,[^{}]*)\}/.exec(p);
  53 + if (!m) return [p];
  54 + return m[1]
  55 + .split(',').map((s) => s.trim()).filter((s) => s && s !== '...')
  56 + .flatMap((alt) => expandAlternatives(p.slice(0, m.index) + alt + p.slice(m.index + m[0].length)));
  57 +}
  58 +
  59 +/** 段级归一:占位符(`:x` / `<x>` / `{x}`)一律 `*`,字面量原样保留 */
  60 +function segments(p) {
  61 + return String(p)
  62 + .split('/')
  63 + .filter((s) => s !== '')
  64 + .map((s) => (/[:<>{}*]/.test(s) ? '*' : s));
  65 +}
  66 +
  67 +/**
  68 + * 严格逐段相等(占位符只与占位符相等)。
  69 + *
  70 + * 早先写成「`*` 匹配任意一段」,结果 `/api/company/{公司ID}/work-graphs` 被
  71 + * `PATCH /api/company/templates/:templateId` 匹配上了(占位符吃掉 `templates`、
  72 + * `work-graphs` 又被 `:templateId` 吃掉)—— 那样这条 guard 对本次要挡的缺陷直接失效。
  73 + * 所以「字面量位置上出现占位符」也算不匹配。
  74 + */
  75 +function pathMatches(a, b) {
  76 + const x = segments(a);
  77 + const y = segments(b);
  78 + return x.length === y.length && x.every((s, i) => s === y[i]);
  79 +}
  80 +
  81 +/** 一个模板路径「已注册」= 它展开出的每一条候选都能找到注册 */
  82 +function isRegistered(p, registered) {
  83 + return expandAlternatives(p).every((cand) => registered.some((r) => pathMatches(cand, r.full)));
  84 +}
  85 +
  86 +/** server/index.js 的挂载表:`app.use('/api/x', require('./routes/y').z)` */
  87 +function readMounts() {
  88 + const src = fs.readFileSync(INDEX_JS, 'utf-8');
  89 + const re = /app\.use\(\s*'([^']+)'\s*,\s*require\('\.\/routes\/([\w-]+)'\)(?:\.(\w+))?\s*\)/g;
  90 + const out = [];
  91 + let m;
  92 + while ((m = re.exec(src))) out.push({ prefix: m[1], file: `${m[2]}.js`, exportName: m[3] || null });
  93 + return out;
  94 +}
  95 +
  96 +/** 这个挂载点对应的 Router 变量名(`module.exports = router` / `module.exports.companyRouter = x`) */
  97 +function routerIdentifier(src, exportName) {
  98 + const re = exportName
  99 + ? new RegExp(`module\\.exports\\.${exportName}\\s*=\\s*(\\w+)`)
  100 + : /module\.exports\s*=\s*(\w+)\s*;/;
  101 + const m = re.exec(src);
  102 + return m ? m[1] : null;
  103 +}
  104 +
  105 +/** 真实注册的完整路径集合(前缀 + 声明路径),只收字面量路径 */
  106 +function registeredPaths() {
  107 + const out = [];
  108 + for (const mount of readMounts()) {
  109 + const file = path.join(ROUTES_DIR, mount.file);
  110 + if (!fs.existsSync(file)) continue;
  111 + const src = fs.readFileSync(file, 'utf-8');
  112 + const ident = routerIdentifier(src, mount.exportName);
  113 + if (!ident) continue;
  114 + const re = new RegExp(`\\b${ident}\\.(?:get|post|put|patch|delete)\\(\\s*'([^']*)'`, 'g');
  115 + let m;
  116 + while ((m = re.exec(src))) {
  117 + out.push({ full: `${mount.prefix}/${m[1]}`.replace(/\/+/g, '/'), file: mount.file });
  118 + }
  119 + }
  120 + return out;
  121 +}
  122 +
  123 +/**
  124 + * 模板里提到的每个 `/api/...` 路径(带出处行号,便于定位)。
  125 + *
  126 + * 占位符里有中文(`<公司ID>`),所以 ASCII 字符类**不够** —— 第一版写成
  127 + * `[A-Za-z0-9_\-./:<>{},]*` 时,`/api/company/<公司ID>/agents` 被截成 `/api/company/<`,
  128 + * 段数从 4 塌成 3,正好被 `GET /api/company/:companyId` 匹配上:整条 guard 对模板里
  129 + * **每一条**真实路径都变成了空跑(变异测试实测撞到)。CJK 只在 `<>` / `{}` 括号里放行,
  130 + * 括号外仍限 ASCII —— 否则紧跟路径的中文正文会被吞进路径里,变成误报。
  131 + */
  132 +const ENDPOINT_RE = /\/api\/(?:<[^<>\s]*>|\{[^{}\s]*\}|[A-Za-z0-9_\-.:]|\/)+/g;
  133 +
  134 +function templateEndpoints() {
  135 + const out = [];
  136 + for (const name of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) {
  137 + const lines = fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf-8').split('\n');
  138 + lines.forEach((line, i) => {
  139 + for (const raw of line.match(ENDPOINT_RE) || []) {
  140 + const cleaned = raw.replace(/[.,:;/]+$/, '');
  141 + out.push({ path: cleaned, file: name, line: i + 1, exempt: line.includes(NO_SUCH) });
  142 + }
  143 + });
  144 + }
  145 + return out;
  146 +}
  147 +
  148 +/** ```json 围栏块里能解析出 `nodes` 数组的那些(引擎 extractPlannedGraph 同一取法) */
  149 +function graphExamples(fileName) {
  150 + const text = fs.readFileSync(path.join(TEMPLATE_DIR, fileName), 'utf-8');
  151 + const out = [];
  152 + const fence = /```json\s*([\s\S]*?)```/g;
  153 + let m;
  154 + while ((m = fence.exec(text))) {
  155 + try {
  156 + const doc = JSON.parse(m[1]);
  157 + if (doc && Array.isArray(doc.nodes)) out.push(doc);
  158 + } catch { /* 不是图的 json 块(或写坏了),交给下面的断言去管 */ }
  159 + }
  160 + return out;
  161 +}
  162 +
  163 +describe('workgraph-planning 模板:教的端点必须真的存在', () => {
  164 + const registered = registeredPaths();
  165 + const endpoints = templateEndpoints();
  166 +
  167 + it('两侧都不是空的(防止 guard 因为改名/正则失效而静默变成空跑)', () => {
  168 + expect(endpoints.length).toBeGreaterThan(0);
  169 + expect(registered.length).toBeGreaterThan(20);
  170 + // 抽取必须**整条**抽出来(含中文占位符):截断成 `/api/company/<` 会被
  171 + // `GET /api/company/:companyId` 匹配上,guard 就整体空跑了
  172 + for (const tail of ['/hire', '/agents']) {
  173 + const hit = endpoints.find((e) => e.path.endsWith(tail));
  174 + expect(hit, `模板里抽不到以 ${tail} 结尾的端点,抽取正则多半又被截断了`).toBeTruthy();
  175 + expect(segments(hit.path).length).toBe(4);
  176 + }
  177 + // 已知一定注册着的几条,用来验「解析链路本身没坏」(含挂在 companyRouter 上的那条)
  178 + for (const known of ['/api/company/<id>/hire', '/api/company/<id>/agents',
  179 + '/api/graph-runs/<id>/{start,next}', '/api/company/<id>/graph-runs']) {
  180 + expect(isRegistered(known, registered), `解析不到 ${known}`).toBe(true);
  181 + }
  182 + // 反面自检:一个明摆着不存在的路径不许被判成已注册(挡住"匹配太松 → guard 空跑")
  183 + expect(isRegistered('/api/company/<id>/definitely-not-a-route', registered)).toBe(false);
  184 + });
  185 +
  186 + it('① 模板里的每个端点都在 server/routes/ 里真实注册', () => {
  187 + const dangling = endpoints
  188 + .filter((e) => !e.exempt)
  189 + .filter((e) => !isRegistered(e.path, registered))
  190 + .map((e) => `${e.file}:${e.line} → ${e.path} 没有任何路由注册它`
  191 + + `(真要写成反例,同一行加上 ${NO_SUCH} 标记并写明"不存在,不要调用")`);
  192 + expect(dangling).toEqual([]);
  193 + });
  194 +
  195 + it(`② 标了 ${NO_SUCH} 的端点必须确实不存在(端点真上线了就该删掉这句警示)`, () => {
  196 + const exempt = endpoints.filter((e) => e.exempt);
  197 + // 反例本身也要在(否则这条用例是空跑):删掉那条警示行时这里会红,提醒别把它悄悄弄没了
  198 + expect(exempt.length).toBeGreaterThan(0);
  199 + const stale = exempt
  200 + .filter((e) => isRegistered(e.path, registered))
  201 + .map((e) => `${e.file}:${e.line} → ${e.path} 已经真的注册了,${NO_SUCH} 这句话过时了`);
  202 + expect(stale).toEqual([]);
  203 + });
  204 +
  205 + it('③ 模板教 agent 交回的图示例:严格 JSON + 过 validateWorkGraph(引擎用 JSON.parse 取它)', () => {
  206 + // 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 一行不碰。
  207 + // validateWorkGraph 是纯函数,但 require 链上有 getDb,保险起见照铁律走。
  208 + process.env.GRAPH_DB_PATH = '/tmp/test-workgraph-planning-endpoints.sqlite';
  209 + for (const m of ['../../services/graph-db', '../../services/work-graph-db']) {
  210 + delete require.cache[require.resolve(m)];
  211 + }
  212 + const wdb = require('../../services/work-graph-db');
  213 +
  214 + // agent.md 是**唯一进提示词的那份**(agent-manager.getAgentConfig 只把 agent.md 当 prompt),
  215 + // 所以它里面必须有一份可直接照抄的图示例。
  216 + const examples = graphExamples('agent.md');
  217 + expect(examples.length).toBeGreaterThan(0);
  218 +
  219 + const bad = [];
  220 + for (const dir of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) {
  221 + for (const g of graphExamples(dir)) {
  222 + const verdict = wdb.validateWorkGraph(g.nodes, g.edges || []);
  223 + if (!verdict.ok) bad.push(`${dir}: ${JSON.stringify(verdict.errors)}`);
  224 + }
  225 + }
  226 + expect(bad).toEqual([]);
  227 + });
  228 +});
  229 +
  230 +/**
  231 + * ④ 引擎自己拼的规划提示词,不许与模板对立。
  232 + *
  233 + * 第六次真 CLI 冒烟挖出来的:同一条提示词里,模板部分(系统提示词)写着
  234 + * 「不许出现 wg_ + 12 位十六进制,你永远拿不到真的 wg_ id」,而引擎拼的任务描述部分
  235 + * 写着「已经自行落库成功时,改为回一行 workGraphId: wg_xxx」——**两边直接对立**,
  236 + * agent 得赌听谁的。那一轮它选择听模板,是运气不是保证。
  237 + *
  238 + * 同型的还有两条:引擎的内联图示例一个 produces 都没有(agent 唯一见过的「边长什么样」
  239 + * 就是零声明的),以及引擎说端点「尚未上线」(暗示将来会上线,于是它还想去试一次)。
  240 + *
  241 + * 手法与上面一致:**静态读源码文本**,不 require graph-engine(一 require 就拉起 sqlite
  242 + * 与 AgentManager 依赖链)。buildPlanningPrompt 不导出,也不为测试新开导出面。
  243 + * ponytail: 按「## 交回方式」到数组收尾 `];` 之间的文本切段。上限是节标题一改就切不到 ——
  244 + * 所以下面第一条断言先钉住「这段必须切得到且非空」,切不到时是它红而不是静默空跑。
  245 + */
  246 +describe('引擎的规划提示词:与 workgraph-planning 模板口径一致', () => {
  247 + const ENGINE_SRC = path.join(ROOT, 'server/services/graph-engine.js');
  248 + const src = fs.readFileSync(ENGINE_SRC, 'utf-8');
  249 +
  250 + /**
  251 + * buildPlanningPrompt 里「## 交回方式」那一节里**真正会进提示词的文本**。
  252 + *
  253 + * 必须剔掉 `//` 注释行:这一节的注释本身就在解释「为什么不许说某句话」,
  254 + * 连注释一起扫的话,写下禁令理由的那条注释会让 guard 自己红
  255 + * (第一版就是这样红的 —— 它量的是源码文本,而该量的是发给 agent 的文本)。
  256 + */
  257 + function handoffSection() {
  258 + const start = src.indexOf('## 交回方式');
  259 + if (start < 0) return '';
  260 + const end = src.indexOf('\n ];', start);
  261 + if (end < 0) return '';
  262 + return src.slice(start, end)
  263 + .split('\n')
  264 + .filter((l) => !l.trim().startsWith('//'))
  265 + .join('\n');
  266 + }
  267 +
  268 + it('这一节切得到(切不到时这条红,而不是让下面三条静默空跑)', () => {
  269 + const sec = handoffSection();
  270 + expect(sec.length).toBeGreaterThan(100);
  271 + // 它必须确实是在讲交回图这件事,否则说明切到了别的地方
  272 + expect(sec).toContain('json');
  273 + });
  274 +
  275 + it('不邀请 agent 回 workGraphId(模板明令禁止写 wg_ id,两边不许对立)', () => {
  276 + const sec = handoffSection();
  277 + // 找「教它输出 wg_」的措辞。引擎侧仍可以保留**解析**真实 wg_ id 的能力,
  278 + // 禁的只是在提示词里邀请它写一个 —— 它没有任何受支持的落库途径,写出来必是编的。
  279 + expect(sec).not.toMatch(/workGraphId\s*[::]\s*wg_/);
  280 + expect(sec).not.toContain('自行落库成功');
  281 + });
  282 +
  283 + it('内联图示例的边带 produces(示例是 agent 唯一见过的「边长什么样」)', () => {
  284 + const sec = handoffSection();
  285 + const edges = sec.match(/"edges"\s*:\s*\[[^\]]*\]/);
  286 + expect(edges).not.toBeNull();
  287 + expect(edges[0]).toContain('produces');
  288 + });
  289 +
  290 + it('提到 work-graphs 端点时不说「尚未上线」(暗示将来会上线,agent 就还会去试)', () => {
  291 + const sec = handoffSection();
  292 + if (!sec.includes('work-graphs')) return; // 不提它也行,那就没这个问题
  293 + expect(sec).not.toContain('尚未上线');
  294 + expect(sec).not.toContain('还没上线');
  295 + });
  296 +});
... ...
system/templates/orggraph-planning/agent.md
... ... @@ -76,7 +76,10 @@ node bin/lemon.js company templates
76 76  
77 77 ### Step 4 · 规划
78 78  
79   -综合 Step 1/2/3 三份输入,产出一张 workflow:**节点列表 + 执行流边**。
  79 +综合 Step 1/2/3 三份输入,产出一张 workflow:**节点列表 + 执行流边 + 边上的数据契约**。
  80 +
  81 +> 🚨 **第三项不是可选的。** 边上不声明数据契约(`produces` / `needs`),
  82 +> 下游 org node 就拿不到上游 org node 的产出 —— 规则全在**约束五**,画图时就照着写。
80 83  
81 84 你**被明确允许**:
82 85  
... ... @@ -110,7 +113,7 @@ org-planner --success--&gt; org-{执行职能} --success--&gt; org-validator
110 113 mkdir -p workspace
111 114 ```
112 115  
113   -文件至少包含五节:
  116 +文件至少包含六节:
114 117  
115 118 ```markdown
116 119 # {公司名}({公司id})Org Graph 规划记录
... ... @@ -140,6 +143,12 @@ mkdir -p workspace
140 143 | 2 | 单一入口 | 入度为 0 的只有 a |
141 144 | 3 | 全可达 | 从 a 出发 BFS 覆盖全部 3 个节点 |
142 145 | 4 | 回退边显式标注 | c → b 已标 edgeKind: backward |
  146 +
  147 +## 6. 数据契约自查(约束五)
  148 +| 边 | produces | needs | 谁产出它 |
  149 +|---|---|---|---|
  150 +| a → b | ["prd"] | — | a 这个 org node 的子图必须产出一份 prd |
  151 +| b → c | ["artifact"] | ["prd"] | prd 来自 a(非紧邻时才写 needs) |
143 152 ```
144 153  
145 154 **这一步不能跳。** §八 的风险清单里写明:图的**规划质量没有硬校验**,只有结构校验。
... ... @@ -176,8 +185,8 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/org-graph \
176 185 { "roleName": "org-validator", "systemAgentId": "org-validator" }
177 186 ],
178 187 "edges": [
179   - { "from": "org-planner", "to": "org-coder", "condition": "success" },
180   - { "from": "org-coder", "to": "org-validator", "condition": "success" },
  188 + { "from": "org-planner", "to": "org-coder", "condition": "success", "produces": ["prd"] },
  189 + { "from": "org-coder", "to": "org-validator", "condition": "success", "produces": ["artifact"] },
181 190 { "from": "org-validator", "to": "org-coder", "condition": "fail", "edgeKind": "backward" }
182 191 ]
183 192 }
... ... @@ -186,6 +195,8 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/org-graph \
186 195 - `systemAgentId` 可省略,省略时默认等于 `roleName`。
187 196 - `edgeKind` 省略时默认 `forward`。
188 197 - 节点也可以直接写成字符串 `"org-coder"`,等价于 `{roleName:"org-coder"}`。
  198 +- `produces` / `needs` 是**数据契约**(约束五):**每条 forward 边都要写 `produces`**;
  199 + `backward` 回退边上一个字都不许写。
189 200  
190 201 #### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试
191 202  
... ... @@ -198,8 +209,10 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/org-graph \
198 209 | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,把其余孤立起点接到它的下游 |
199 210 | `reachable` | 有节点从入口沿 forward 边走不到(孤立节点) | 给它补一条上游 forward 边,或者它本来就不该存在,删掉 |
200 211 | `backward-labeled` | 某条边指向拓扑序更早的节点却没标 `backward` | 给这条边补 `"edgeKind": "backward"` |
201   -| `schema` | 字段缺失 / 取值非法 / roleName 重复 | 按 `detail` 逐条改 |
  212 +| `schema` | 字段缺失 / 取值非法 / roleName 重复;**也包括数据契约的 kind 自创、或把契约写到了 `backward` 边上** | 按 `detail` 逐条改,取值域见约束五 |
202 213 | `reference` | 边引用了 `nodes` 里不存在的角色名 | 拼写错了,或忘了把这个节点加进 `nodes` |
  214 +| `data-contract` | 某条边声明下游要吃 kind=X(`needs`),而图上没有任何节点的出边声明产出 X | 二选一:给真正的产出方那条出边补 `"produces":["X"]`;产出方确实不在这张图(由调用方通过 `artifacts/` 给)就改成 `"needs":[{"kind":"X","required":false}]`。见约束五 |
  215 +| `fail-fallback-edge` | 某节点有 `fail` 出边,却没有一条不带 `failReason` 的兜底边 | 补一条不带 `failReason` 的 `fail` 边 |
203 216  
204 217 **🚨 铁律**:
205 218 1. **不要放弃**——400 是设计里预期的一轮交互,不是失败。
... ... @@ -318,6 +331,81 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato
318 331 这类**不参与任务执行流**的角色。org graph 回答的是「**一个任务怎么流过这些人**」,
319 332 不是「公司里有谁」——后者的真相源是 `company.json.agents[]`,两者并存、各管各的。
320 333  
  334 +### 约束五:forward 边上必须声明数据契约 🚨(R-19,27-P4 §4.6)
  335 +
  336 +**这条决定的是「第二个 org node 接到任务时手上有没有东西」,不是文档洁癖。**
  337 +
  338 +org graph 的边只承载控制流(`condition` / `edgeKind` / `failReason`),**不携带任何文件引用**。
  339 +不声明数据契约的话,下游 org node 的产出全躺在上游那个 org node 子图的节点私有目录里,
  340 +而跨节点私有目录是禁止读的 —— 下游只能说「我拿不到输入」,而它说这句话时
  341 +整个上游子图的预算已经烧掉了。第四次真 CLI 冒烟里 work 层就是这么挂的,原话是
  342 +**「不是上游没产出,是它的产出结构上到不了我手上。」**
  343 +
  344 +你声明之后,引擎会做两件事(都不需要你操心怎么做):
  345 +
  346 +1. **递输入**:下游 org node 派活给 `workgraph-planning` 时,引擎按这条边上的 kind
  347 + 去产物索引查真实文件,把**绝对路径**拼进那份规划提示词;
  348 +2. **下压产出约束**:上游 org node 派活时,引擎把它出边上的 kind 作为**硬约束**告诉
  349 + `workgraph-planning`——「你规划的这张子图里必须有节点真的产出这些」。
  350 +
  351 +**你不声明 → 这两件事都不发生,两层之间就没有任何数据流。**
  352 +
  353 +#### 声明的是「契约」,不是文件名
  354 +
  355 +规划时一个节点都还没跑,**谁也不知道文件叫什么**。你声明的是**类型**:「这条边传一份 `prd`」。
  356 +实例(真实路径)由引擎在运行时登记。**不要在图里写文件名、不要编路径。**
  357 +
  358 +#### 粒度:org 层比 work 层粗一档
  359 +
  360 +org node 是**一整个子图**,不是一个 agent 调用。所以这里声明的是
  361 +「这个 org node 整体交出去的东西」,通常一条边**一到两个** kind 就够了。
  362 +不要把子图内部的中间产物(某个 coder 写的一份日志、某轮 attempt 的归因)搬到 org 边上
  363 +—— 那些是 `workgraph-planning` 在下一层自己声明的事,你管不着也不该管。
  364 +
  365 +#### 两个键,为什么是两个
  366 +
  367 +| 键 | 写在哪条边上 | 语义 |
  368 +|---|---|---|
  369 +| `produces` | 产出方的**出边** | 这条边的上游 org node 会交出这些 kind。**forward 边基本都要写** |
  370 +| `needs` | 消费方的**入边** | 下游要吃这些 kind,但产出方**不是**紧邻的上游 org node(隔了一个节点的中继)。少数情况才用 |
  371 +
  372 +```jsonc
  373 +{"from":"org-planner","to":"org-coder","condition":"success","produces":["prd"]}
  374 +{"from":"org-coder","to":"org-validator","condition":"success","produces":["artifact"],"needs":["prd"]}
  375 +```
  376 +
  377 +**不许把两个键合成一个。** 合成之后「我要吃 X」和「有人产 X」变成同一行断言,
  378 +「声明要吃 X 但全图没人产 X」就永远自洽 —— 校验规则 `data-contract` 会退化成一条
  379 +永远不报错的空跑规则,而它恰好是唯一能在跑之前发现这类断链的地方。
  380 +
  381 +三种写法都收:`"produces":"prd"` / `"produces":["prd","sdd"]` /
  382 +`"produces":[{"kind":"prd","required":false}]`(`required:false` = 缺了不算错,
  383 +留给「产出方不在本图」的情形)。
  384 +
  385 +#### kind 的取值域(自创取值会被 `rule=schema` 打回整张图)
  386 +
  387 +`brd`(业务需求)· `prd`(产品需求)· `sdd`(系统设计)· `acceptance`(验收契约)·
  388 +`contract`(接口契约)· `artifact`(一般交付物:代码 / 数据 / 报告)· `log`(运行与测试日志)
  389 +
  390 +大小写会被归一(`"PRD"` == `"prd"`),但 `blueprint` / `design-doc` 这类一律被拒。
  391 +拿不准就用 `artifact`:它是显式的兜底值,比自创一个名字强得多。
  392 +
  393 +#### 两条硬规则
  394 +
  395 +1. **`backward` 回退边上不许声明**(`rule=schema` 直接打回)。引擎解析输入只看 forward 入边,
  396 + 写在回退边上等于没写,而「看起来写了」比「没写」更难排查。
  397 + 验收打回执行的那条 `fail` 边永远是 `backward`,所以它永远是空的。
  398 +2. **`needs` 的 kind 必须在图里有产出方**,否则 `rule=data-contract` 整张图被拒。
  399 + 修法二选一:给产出方那条出边补 `"produces":["X"]`;产出方确实不在这张 org 图里
  400 + (由调用方通过任务级 `artifacts/` 给)就改成 `"needs":[{"kind":"X","required":false}]`。
  401 +
  402 +#### 一条自查
  403 +
  404 +把图上每一条 forward 边逐条念一遍:**「上游这个 org node 交出去的是什么,
  405 +下游那个 org node 靠什么开工?」** 念不出名字的边就是没想清楚,
  406 +别写一个 `artifact` 蒙过去 —— 写完还要能在 `workspace/orggraph-plan.md` 第 6 节
  407 +把「谁产出它」填进那张表。
  408 +
321 409 ---
322 410  
323 411 ## 强制思维隔离
... ... @@ -334,6 +422,11 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato
334 422 哪些是清单里现成的、哪些是要新建的?我心里有数吗?
335 423 4. 我这张图过四项校验了吗?——环?入口恰好一个?每个节点都可达?
336 424 验收打回执行的那条 fail 边标 backward 了吗?
  425 +4bis. **每条 forward 边上都写了 `produces` 吗?**(约束五)——不写的话下游 org node
  426 + 派活时提示词里没有上游产物路径,它必然说「我拿不到输入」。
  427 + kind 是取值域里那七个吗,还是我自创了一个?我有没有把契约写到 backward 边上(等于没写)?
  428 + `needs` 的每个 kind 在图里都有产出方吗,还是该改成 `required:false`?
  429 + 我是不是把子图内部的中间产物搬到 org 边上了(粒度错了,那是下一层的事)?
337 430 5. 角色名全都带领域前缀了吗?有没有裸用 planner / validator?
338 431 有没有撞上那 6 个冲突 id?图里有没有混进 orggraph-planning 自己?
339 432 6. workspace/orggraph-plan.md 写了吗?里面写清「为什么砍/为什么加」了吗,
... ...
system/templates/orggraph-planning/tools.md
... ... @@ -41,11 +41,15 @@ node bin/lemon.js company templates
41 41  
42 42 ```jsonc
43 43 {"nodes":[{"roleName":"org-planner"},{"roleName":"org-coder"},{"roleName":"org-validator"}],
44   - "edges":[{"from":"org-planner","to":"org-coder","condition":"success"},
45   - {"from":"org-coder","to":"org-validator","condition":"success"},
  44 + "edges":[{"from":"org-planner","to":"org-coder","condition":"success","produces":["prd"]},
  45 + {"from":"org-coder","to":"org-validator","condition":"success","produces":["artifact"],"needs":["prd"]},
46 46 {"from":"org-validator","to":"org-coder","condition":"fail","edgeKind":"backward"}]}
47 47 ```
48 48  
  49 +> `produces` / `needs` 就是 `agent.md` 约束五那两个键。**forward 边上少了它们,
  50 +> 下游 org node 派活时提示词里就没有上游产物的路径** —— 先读约束五再写这份 JSON。
  51 +> `backward` 边上不许写(上面第三条边就是空的,那不是漏了)。
  52 +
49 53 **再发送**:
50 54  
51 55 ```bash
... ... @@ -63,10 +67,15 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/org-graph \
63 67 | `edges[].from` / `to` | **用角色名**,不是数字 id(图还没落库,你手里没有 id) |
64 68 | `edges[].condition` | `"success"` \| `"fail"`,必填 |
65 69 | `edges[].edgeKind` | `"forward"`(默认可省略)\| `"backward"` |
  70 +| `edges[].produces` | **数据契约**:这条边的**上游 org node** 会交出哪些 kind。见约束五,**forward 边基本都要写** |
  71 +| `edges[].needs` | **数据契约**:下游要吃哪些 kind,而产出方**不是**紧邻的上游 org node(隔节点中继)。见约束五 |
  72 +
  73 +kind 取值域(自创会被 `rule=schema` 打回):
  74 +`brd` / `prd` / `sdd` / `acceptance` / `contract` / `artifact` / `log`。拿不准用 `artifact`。
66 75  
67 76 **400 的处理见 `agent.md` Step 6 的规则对照表**:`errors[].rule` 取值为
68   -`acyclic` / `single-entry` / `reachable` / `backward-labeled` / `schema` / `reference`。
69   -读懂 → 改图 → 重试,最多 5 轮。
  77 +`acyclic` / `single-entry` / `reachable` / `backward-labeled` / `fail-fallback-edge` /
  78 +`data-contract` / `schema` / `reference`。读懂 → 改图 → 重试,最多 5 轮。
70 79  
71 80 **回读确认**:
72 81  
... ... @@ -110,8 +119,8 @@ curl -s -X PUT http://localhost:3010/api/company/&lt;公司ID&gt;/agents/orggraph-plan
110 119 mkdir -p workspace
111 120 ```
112 121  
113   -然后用文件写入工具写 `workspace/orggraph-plan.md`,五节结构见 `agent.md` Step 5。
114   -**图 + 理由 + 四项校验自查**,三样缺一不可——这是人类事后复核你判断力的唯一线索。
  122 +然后用文件写入工具写 `workspace/orggraph-plan.md`,六节结构见 `agent.md` Step 5。
  123 +**图 + 理由 + 四项校验自查 + 数据契约自查**,四样缺一不可——这是人类事后复核你判断力的唯一线索。
115 124  
116 125 ---
117 126  
... ...
system/templates/workgraph-planning/agent.md
... ... @@ -7,7 +7,8 @@
7 7 你在不同 org node / 不同公司下的差异,靠**记忆分区**承载,不靠复制本体。
8 8  
9 9 你的职责有两件,**顺序不可颠倒**:为**某一个 org node 的这一次任务**规划出一张 **work graph**,
10   -**先把这张图引用到的本体全部雇进本公司**,再提交图、把图 id 交回引擎。
  10 +**先把这张图引用到的本体全部雇进本公司**,再把图交回引擎。
  11 +**落库不是你的活**——你没有任何可调的落库端点,引擎读你的回复取图、自己校验、自己写库(Step 7)。
11 12 你不执行图、不改 org graph。
12 13  
13 14 > 🚨 **「图里引用到的本体由你雇」是 R-18.2 的定案,不是可选项。**
... ... @@ -26,10 +27,10 @@
26 27 | 角色 | 干什么 | 与你的关系 |
27 28 |---|---|---|
28 29 | `orggraph-planning` | 建公司时跑一次,规划**公司级** org graph,并把 **org 层的节点**招齐 | 上一层。它招 org 节点,**你招你图里的 work 层本体**,两批人互不重叠 |
29   -| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph,**并雇齐图里引用的本体** | 雇完 + 提交图 + 交出 `workGraphId` 才算结束 |
  30 +| **你(`workgraph-planning`)** | 每个 org node 每次接到任务时跑一次,规划**该节点这一次**的 work graph,**并雇齐图里引用的本体** | 雇完 + 把图交回引擎才算结束 |
30 31 | 图上的节点 agent | 真正干活的人 | 你**引用**它们、**把它们雇进公司**,但不代替它们干活 |
31 32 | `validator-triager` | 归因「这次失败该怪谁」 | 它是 `org-validator` work graph 里的**一个节点**,由你画进图里 |
32   -| 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是你落库后的消费者 |
  33 +| 引擎(`graph-engine`) | 起子 run、按边推进、熔断、透传 | 你的调用方,也是**读你的回复取出图并落库**的那一方 |
33 34  
34 35 ⚠️ **你自己不出现在你规划的图里。** 你是基础设施。图里出现 `workgraph-planning` 是**明确的错误**。
35 36  
... ... @@ -38,18 +39,25 @@
38 39 ## ⚡ 强制工作流(八步,必须在同一轮对话内连贯做完)
39 40  
40 41 > **🚨 绝不允许只画完图就停下来等指示。** 从 Step 1 到 Step 8 是一条不可中断的流水线。
41   -> 人没雇齐、图没过校验、没落库、`workGraphId` 没交回去,任务就没完成。
  42 +> 人没雇齐、图没交回去,任务就没完成。
42 43  
43 44 | Step | 干什么 |
44 45 |---|---|
45 46 | 1-4 | 读输入 → 读骨架 → 读本体清单 → 规划 |
46 47 | 5 | 落盘规划记录 |
47 48 | **6** | **执行招聘(雇齐图里引用的本体)** |
48   -| **7** | **提交图** |
49   -| 8 | 交回引擎 |
  49 +| **7** | **把图交回引擎(引擎校验 + 落库)** |
  50 +| 8 | 结构化汇报 |
50 51  
51 52 **6 在 7 前面是写死的,不是建议。** 理由见 Step 6 开头那段红字。
52 53  
  54 +> 🧭 **路径口径(下面所有命令都受它约束)**:你的 CLI 进程 **cwd 是你自己的 agent 目录**
  55 +> (`data/companies/<公司ID>/agents/workgraph-planning`),**不是引擎仓库根**。
  56 +> 所以本页写成仓库相对形式的路径(`system/work-graph-templates/`、`bin/lemon.js`、
  57 +> `data/companies/...`)**都要自己拼上引擎仓库根**再用,否则命令会以「文件不存在」失败。
  58 +> 引擎仓库根的绝对路径就在首轮消息的「写入边界」段里(那句「禁止写 `<绝对路径>` 下的任何路径」)。
  59 +> **禁的只有写,读是允许的**——`ls` / `cat` 骨架与本体清单照读。
  60 +
53 61 ### Step 1 · 读输入(四项)
54 62  
55 63 首轮消息里会带入这四项,**一项都不能跳过不读**:
... ... @@ -124,9 +132,9 @@ curl -s http://localhost:3010/api/company/&lt;公司ID&gt;/agents
124 132  
125 133 你**不被允许**:
126 134  
127   -- 不加思考地照抄某个骨架原样提交(那说明你没做 Step 1 的判断,而且**骨架里没有数据契约**,
  135 +- 不加思考地照抄某个骨架原样交回(那说明你没做 Step 1 的判断,而且**骨架里没有数据契约**,
128 136 照抄出来的图每条边都不传数据);
129   -- 留着 `{{orgNode}}` / `{{implAgent}}` 这类占位符就提交(落库出来是一张引用不存在 agent 的废图);
  137 +- 留着 `{{orgNode}}` / `{{implAgent}}` 这类占位符就交回(引擎落出来是一张引用不存在 agent 的废图);
130 138 - 把不同职能塞进同一个节点(**一个节点 = 一件能一句话说清「做完什么算成功」的事**);
131 139 - 画出违反下面「五项校验」的图;
132 140 - **在节点内部再套一层四段式流转**(约束四,最容易犯,务必读完)。
... ... @@ -141,11 +149,16 @@ curl -s http://localhost:3010/api/company/&lt;公司ID&gt;/agents
141 149  
142 150 ### Step 5 · 落盘规划记录
143 151  
144   -把图和**为什么这么设计的理由**写进 `workspace/workgraph-plan-<taskId>.md`,供事后人工审计:
  152 +把图和**为什么这么设计的理由**写成一份 `workgraph-plan-<taskId>.md`,供事后人工审计。
145 153  
146   -```bash
147   -mkdir -p workspace
148   -```
  154 +**落点看首轮消息的「写入边界」段**,不要凭习惯写相对路径:
  155 +
  156 +| 边界段说了什么 | 你写哪儿 |
  157 +|---|---|
  158 +| 给了项目根 | `<项目根>/workgraph-plan-<taskId>.md`,**用绝对路径** |
  159 +| 明说没分到项目根 | **一个文件都不落**,把这份记录直接写进回复正文 |
  160 +
  161 +⚠️ 相对路径(`workspace/...`)会落到**引擎源码仓库**里——那正是边界段禁止写的位置。
149 162  
150 163 文件至少包含七节(第 7 节在 Step 6 招完人之后回填):
151 164  
... ... @@ -171,7 +184,7 @@ mkdir -p workspace
171 184 |---|---|---|---|
172 185  
173 186 ## 4. 最终图
174   -(节点表 + 边表,与提交给 API 的 JSON 一致)
  187 +(节点表 + 边表,与 Step 7 交回引擎的那份 JSON 一致)
175 188  
176 189 ## 5. 与骨架的差异及理由
177 190 - 砍掉了 xxx,因为 ...
... ... @@ -200,16 +213,17 @@ mkdir -p workspace
200 213  
201 214 ### Step 6 · 执行招聘(雇齐图里引用的本体)
202 215  
203   -> 🚨🚨 **顺序写死:先雇人,再提交图。反过来必挂,且是两处一起挂。**
  216 +> 🚨🚨 **顺序写死:先雇人,再交回图。反过来必挂,且是两处一起挂。**
204 217 >
205 218 > | 反了会发生什么 | 为什么 |
206 219 > |---|---|
207   -> | 提交图时 400 `rule=reference` / 外键报错 | `work_graph_nodes.system_agent_id` 是**指向 `system_agents` 表的外键**。模板库里没有的本体(Step 3 情形 3)只有被 `hire` 雇过一次,才会有那行记录 |
208   -> | 图提交成功了,节点跑起来还是 `Agent not found` | 引擎解析本体名的判据是 **`data/companies/<公司ID>/agents/<本体名>/` 这个目录在不在**:在 → 用作用域名 `<公司ID>:<本体名>` 寻址;不在 → 退回裸名,而裸名只在 `system/agents/` 下找得到,**你图里的本体一个都不在那儿** |
  220 +> | 引擎落库时 `rule=reference` / 外键报错,把图打回给你重画 | `work_graph_nodes.system_agent_id` 是**指向 `system_agents` 表的外键**。模板库里没有的本体(Step 3 情形 3)只有被 `hire` 雇过一次,才会有那行记录 |
  221 +> | 图落库成功了,节点跑起来还是 `Agent not found` | 引擎解析本体名的判据是 **`data/companies/<公司ID>/agents/<本体名>/` 这个目录在不在**:在 → 用作用域名 `<公司ID>:<本体名>` 寻址;不在 → 退回裸名,而裸名只在 `system/agents/` 下找得到,**你图里的本体一个都不在那儿** |
209 222 >
210   -> **这两条都只有「雇过」才成立,而且都发生在你已经下班之后**——图提交完你就没有机会补救了。
211   -> 所以:**Step 5 写完规划记录 → Step 6 一个不漏地雇完 → Step 7 才允许提交图。**
212   -> 不许「先提交试试,挂了再回来雇」。
  223 +> **这两条都只有「雇过」才成立。** 而且引擎是在你这一轮回复**结束的那一刻**就取图落库的——
  224 +> 图一交出去你就下班了,第二条挂在你下班之后,没有补救窗口。
  225 +> 所以:**Step 5 写完规划记录 → Step 6 一个不漏地雇完 → Step 7 才允许把图交出去。**
  226 +> 不许「先交出去试试,挂了再回来雇」。
213 227  
214 228 **要雇的是哪些**:你图里 `nodes[].systemAgentId` 的**全集去重**(省略 `systemAgentId` 的节点取 `nodeName`)。
215 229 **不是** `nodeName`——节点名带宿主 org node 前缀,本体名不带(约束二),雇的是**本体名**。
... ... @@ -248,7 +262,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/hire \
248 262 | `hired_from_template` | 模板库里有,走引用模式寻址(不拷贝 agent.md) | ✅ **继续下一个** |
249 263 | `already_exists` | 这家公司**之前的任务已经雇过它了** | ✅ **继续下一个**。这是幂等的正常结果,**不是错误**,不许因此停下、报警、或把节点从图里删掉 |
250 264 | `hired_from_base` | 模板库里没这个角色 → 自动从 `system/templates/base/` 元 agent 继承出四段式骨架,并触发 `agent-creator` 补定义 | ✅ **继续下一个**,但在规划记录第 7 节记一笔,Step 8 汇报时点名 |
251   -| `training` | 连 base 元 agent 都不在 → 走了纯脚手架兜底 | ⚠️ **这一档不算成功**:脚手架**不写 `system_agents` 表**,图提交时这个本体会被 `rule=reference` 打回。按下面「换掉」处理 |
  265 +| `training` | 连 base 元 agent 都不在 → 走了纯脚手架兜底 | ⚠️ **这一档不算成功**:脚手架**不写 `system_agents` 表**,引擎落库时这个本体会被 `rule=reference` 打回。按下面「换掉」处理 |
252 266 | 命令报错 / 输出里有 `❌ [CRITICAL ERROR]` | 见下表 | 见下表 |
253 267  
254 268 > ⚠️ **CLI 不会把 HTTP 错误还成 JSON。** 状态码 ≥400 时 `bin/lemon.js` 打印的是
... ... @@ -265,7 +279,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/hire \
265 279 | `Details` 里是 `{"error":"role 为必填项"}` | **命令写错了** | 补上 `--role` 重试一次 |
266 280 | 返回 `training`,或返回体里明说「模板不存在且无法继承」 | **这个本体供不出来** | **换掉**:回到 Step 4,用清单里语义最接近的本体替换该节点(或改图形状去掉它),**改完图要重跑 Step 5 的五项校验**,再回到 Step 6 把新本体雇一遍。同一个本体**最多换一次**,换完还不行就停 |
267 281 | 同一个本体重试 3 次仍失败 | 放弃它 | 同上「换掉」 |
268   -| 上面的路都走完还是雇不齐 | **停** | **不许提交一张引用了没雇上本体的图**(提交了也会被打回,还会污染排障线索)。把「哪几个本体没雇上 + 每个的原始错误输出」写进规划记录第 7 节,直接跳到 Step 8 如实汇报,由引擎/人接手 |
  282 +| 上面的路都走完还是雇不齐 | **停** | **不许交回一张引用了没雇上本体的图**(交了也会被引擎打回,还会污染排障线索)。把「哪几个本体没雇上 + 每个的原始错误输出」写进规划记录第 7 节,直接跳到 Step 8 如实汇报,由引擎/人接手 |
269 283  
270 284 #### 幂等与重入
271 285  
... ... @@ -275,7 +289,7 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/hire \
275 289 - 反过来也不要**先查再雇**:`GET /agents` 与 `hire` 之间存在时间差,以 `hire` 的返回为准最简单也最可靠。
276 290 - ⚠️ **`already_exists` 是提前返回,它不补 `system_agents` 那一行。** 所以有一种罕见但会把你卡死的组合:
277 291 某个本体的目录在(雇过 → 恒返回 `already_exists`),`system_agents` 里却没有它的行(比如那目录是被人手工
278   - `mkdir` 出来的)。表现是 **Step 7 提交时 `rule=reference` 一直指向它,而你再雇多少次都只会拿到
  292 + `mkdir` 出来的)。表现是 **引擎落库时 `rule=reference` 一直指向它,而你再雇多少次都只会拿到
279 293 `already_exists`——重雇修不好这一条**。撞上就别在这里死循环:按分级表**换掉**该本体,
280 294 并在规划记录第 7 节写明「目录在但表里没有,重雇无效」,Step 8 点名汇报。
281 295  
... ... @@ -292,67 +306,62 @@ ls data/companies/&lt;公司ID&gt;/agents/
292 306 > **你自己动手写文件**。`company hire` 是让**服务端**去建 `data/companies/<公司ID>/agents/`,
293 307 > 那是公司实例数据区、不在版本控制里,是招聘的**唯一正当落点**。
294 308 > 不许因为这条约束而跳过招聘,也不许自己动手去 `mkdir` / 拷贝目录来"代替"招聘——
295   -> 手工建目录不会写 `system_agents` 表,图照样提交不上去。
  309 +> 手工建目录不会写 `system_agents` 表,图照样落不了库。
296 310  
297   -### Step 7 · 提交图
  311 +### Step 7 · 把图交回引擎(**引擎校验 + 落库,不是你**)
298 312  
299   -```
300   -POST /api/company/{公司ID}/work-graphs
301   -body: { "ownerOrgNodeId": ..., "taskId": ..., "nodes": [...], "edges": [...] }
302   -```
  313 +**你没有落库端点,一个都没有。别去 POST、别去写 sqlite、别自己编 `wg_` id。**
  314 +(`POST /api/company/:id/work-graphs` 这条路径 **NO-SUCH-ENDPOINT — 这个端点不存在,从来没有实现过,不要调用**。
  315 +以前的模板教过它,实测每次都吃 404,白烧一整轮调用。)
303 316  
304   -> 🚨 **绝对禁止在 bash 命令里内联 JSON 字符串**(多重双引号会让你的工具调用崩溃并陷入死循环)。
305   -> **必须先把紧凑单行 JSON 写进 `/tmp/*.json` 文件,再用 `curl -d @文件` 发送。** JSON 里不许有真换行。
  317 +落库是引擎的活:**引擎读你这一轮回复的正文,从里面取出图**,自己跑五项校验、自己写库。
  318 +你要做的只有一件事:**把图放到引擎认得的位置**。引擎认两种形态,二选一(都给也行,围栏块优先):
306 319  
307   -```bash
308   -# 1) 先写文件(用你的文件写入工具写 /tmp/workgraph-<taskId>.json,单行紧凑)
309   -# 2) 再发送
310   -curl -s -X POST http://localhost:3010/api/company/<公司ID>/work-graphs \
311   - -H "Content-Type: application/json" \
312   - -d @/tmp/workgraph-<taskId>.json
  320 +#### 形态 A(首选):把图贴在回复正文的一个 json 围栏块里(三个反引号 + `json`)
  321 +
  322 +```json
  323 +{"nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},{"nodeName":"org-coder-test","systemAgentId":"unit-tester"},{"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],"edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},{"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]},{"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},{"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]}
313 324 ```
314 325  
315   -**body 形状**(边的 `from`/`to` 用**节点名**,不是 id——图还没落库,你手里没有 id):
  326 +#### 形态 B:写成一个 `.json` 文件,在回复里给出它的**绝对路径**
316 327  
317   -```jsonc
318   -{
319   - "ownerOrgNodeId": 12, // 宿主 org node 在 org_graph_nodes 里的 id,首轮消息会给
320   - "goalId": "...", // 一次人类需求描述,唯一,验收边界
321   - "taskId": "...", // 执行单元,一个 goal 多个
322   - "okrNodeId": "O-001.1.1", // 可省略,长周期度量归属
323   - "nodes": [
324   - { "nodeName": "org-coder-impl", "systemAgentId": "coder" },
325   - { "nodeName": "org-coder-test", "systemAgentId": "unit-tester" },
326   - { "nodeName": "org-coder-validator", "systemAgentId": "coder-validator" }
327   - ],
328   - "edges": [
329   - { "from": "org-coder-impl", "to": "org-coder-test", "condition": "success",
330   - "produces": ["artifact"] },
331   - { "from": "org-coder-test", "to": "org-coder-validator", "condition": "success",
332   - "produces": ["log"], "needs": ["artifact"] },
333   - { "from": "org-coder-test", "to": "org-coder-impl", "condition": "fail", "edgeKind": "backward" },
334   - { "from": "org-coder-validator", "to": "org-coder-impl", "condition": "fail", "edgeKind": "backward" }
335   - ]
336   -}
337   -```
  328 +落点与 Step 5 同一条规矩:写在首轮消息「写入边界」段给的**项目根**里面
  329 +(`<项目根>/workgraph-<taskId>.json`),用绝对路径。**没分到项目根就只能用形态 A。**
  330 +写文件用你的文件写入工具,**不要用 bash 拼 JSON**(多重双引号会让工具调用崩溃并陷入死循环)。
  331 +
  332 +⚠️ 引擎认路径的正则只吃 **ASCII 字母数字与 `_ - . /`**:项目根里带空格、中文或别的符号时,
  333 +这条路径**它认不出来**(会被截断成一个不存在的相对路径)。撞上这种项目根就**只用形态 A**。
  334 +
  335 +#### 引擎解析这段的死规矩(踩任何一条 = 白烧一轮)
  336 +
  337 +| 规矩 | 为什么 |
  338 +|---|---|
  339 +| 必须是**严格 JSON** | 引擎用 `JSON.parse`:注释、尾逗号、`...` 省略号、单引号,任一出现整块就当没写。**本页别处的 `jsonc` 示例里的注释是给你读的,别抄进交回的 JSON** |
  340 +| 顶层必须有 **`nodes` 数组** | 引擎就靠「有没有 `nodes` 数组」认哪个块是图;只有 `edges` 的块会被跳过 |
  341 +| 只有 `nodes` / `edges` 会被读 | `ownerOrgNodeId` / `goalId` / `taskId` / `okrNodeId` 由引擎从它自己的 run 上下文填(你写在 JSON 里没人读,不算错但也不生效)。要留痕就写进规划记录第 4 节 |
  342 +| 边的 `from` / `to` 用**节点名** | 图还没落库,你手里没有任何 id |
  343 +| 形态 B 的路径必须**绝对** | 相对路径会被拼到**引擎源码仓库**根下——那正是你被禁止写的位置 |
  344 +| 回复里**只提这一个** `.json` 路径 | 引擎从后往前扫回复里所有 `.json` 并逐个试读,`/tmp/hire-*.json` 之类混在后面只会让它多试几次 |
  345 +| **不许出现 `wg_` + 12 位十六进制** 形状的字符串 | 引擎**优先**匹配这个形状,并当成「你自己已经落库了,这是图 id」,然后去库里查——查不到就判你这一轮无效,重来一轮。你永远拿不到真的 `wg_` id,所以永远别写 |
  346 +
  347 +**字段口径**(与骨架 JSON、与校验器同一套):
338 348  
339 349 - `nodes[].systemAgentId` 可省略,省略时默认等于 `nodeName`——**但你几乎总该显式写**,
340 350 因为节点名带前缀而本体名不带(约束二),两者相等是少数情况。
341 351 - `nodes[].nodeKind` 省略时默认 `agent_call`;只有确实要再下钻一层图的节点才写 `subgraph`。
342   -- `edges[].edgeKind` 省略时默认 `forward`;`edges[].failReason` 省略即为**兜底边**。
  352 +- `edges[].condition` 必填,`"success"` / `"fail"`;`edges[].failReason` 省略即为**兜底边**。
  353 +- `edges[].edgeKind` 省略时默认 `forward`。
343 354 - `edges[].produces` / `edges[].needs` 是**数据契约**(约束九):forward 边基本都要写 `produces`,
344 355 否则下游拿不到上游产物的路径。**backward 边上不许写**。
345 356  
346   -**若该端点返回 404**(引擎侧路由尚未上线):不要重试、不要绕道去写数据库。
347   -把同一份 JSON 原样写到 `workspace/workgraph-<taskId>.json`,在 Step 8 汇报这个路径,由引擎接手落库。
348   -**注意:走降级路径不豁免 Step 6** —— 图迟早要落库,人没雇齐它落库时照样外键挂,而那时你已经下班了。
  357 +#### ⚠️ 引擎把图打回来怎么办:读懂 errors,自己修,重交
349 358  
350   -#### ⚠️ 返回 400 怎么办:读懂 errors,自己修,重试
351   -
352   -400 的响应体里带一个 `errors` 数组,每项形如 `{ "rule": "...", "detail": "..." }`:
  359 +图没过校验时,引擎**不会 HTTP 报错**,而是在下一轮提示词里把一个 `errors` 数组原样贴给你,
  360 +每项形如 `{ "rule": "...", "detail": "..." }`(这就是从前那个 400 的响应体,只是不再经过 HTTP):
353 361  
354 362 | `rule` | 含义 | 怎么修 |
355 363 |---|---|---|
  364 +| **`output`** | **引擎在你上一轮回复里既没找到图 JSON、也没找到能读出图的文件路径** | 你多半是只写了汇报没贴图,或者 JSON 里带了注释/尾逗号解析失败,或者路径写成了相对路径。**照形态 A 重贴一次严格 JSON** |
356 365 | `acyclic` | forward 边构成了环(`detail` 指出闭合于哪条边) | 把这条闭合边改成 `condition:"fail"` + `edgeKind:"backward"`,或删掉 |
357 366 | `single-entry` | 入度为 0 的 forward 节点不是恰好一个 | **0 个**→ 你把入口也画成了别人的下游,去掉那条指回入口的 forward 边;**多个**→ 选定一个真正的入口,其余孤立起点接到它下游(fan-out 要从**一个**拆分节点发散,不是多个入口) |
358 367 | `reachable` | 有节点从入口沿 forward 边走不到 | 补一条上游 forward 边,或者它本来就不该存在,删掉 |
... ... @@ -364,24 +373,29 @@ curl -s -X POST http://localhost:3010/api/company/&lt;公司ID&gt;/work-graphs \
364 373  
365 374 **🚨 铁律**:
366 375  
367   -1. **不要放弃**——400 是设计里预期的一轮交互,不是失败。
368   -2. **不要硬提交**——不许原样重发同一份 body 指望它这次能过。
  376 +1. **不要放弃**——被打回一轮是设计里预期的交互,不是失败。
  377 +2. **不要原样重交**——不许把同一份图再贴一遍指望它这次能过。
369 378 3. **不要绕过校验**——不许直接写数据库、不许把违规节点删光来"通过"。
370   -4. 每次重试前,**先在 `workspace/workgraph-plan-<taskId>.md` 追加一行**:撞了哪条规则、怎么改的。
371   -5. 连续 5 次都过不了,停下来把最后一次的 `errors` 原文汇报给引擎,不要无限重试。
  379 +4. 每次重交前,**先在规划记录 `workgraph-plan-<taskId>.md` 里追加一行**:撞了哪条规则、怎么改的。
  380 +5. **引擎只给你 3 轮**(`PLAN_MAX_ROUNDS`,比这份模板从前写的 5 轮更紧——每一轮都是一次真花钱的调用)。
  381 + 第 3 轮还不过,这个节点就按 fail 结算走兜底边。所以**第一轮就把约束九和五项校验自查做完**,
  382 + 不要把校验当成免费的往返。
372 383  
373   -### Step 8 · 交回引擎
  384 +### Step 8 · 结构化汇报(与 Step 7 的图**同一轮回复**)
374 385  
375   -**一句话结构化汇报**,必须包含:
  386 +汇报和图**不是两轮**:引擎只读你这一轮回复的正文,所以图的围栏块(或文件绝对路径)
  387 +和下面这份汇报要写在**同一条回复**里。汇报必须包含:
376 388  
377   -- `workGraphId`(落库返回的 `wg_...`;若走了 404 降级路径则给出 JSON 文件路径);
378 389 - 图的拓扑序(入口 → … → 末节点);
379 390 - 节点数 / 边数,其中 backward 边几条;
380 391 - 引用了哪些本体,其中哪些**清单里不存在**(Step 3 情形 3);
381 392 - **招聘结果**:雇了几个,各是什么 `status`,其中几个走了 `hired_from_base`,**有没有没雇上的**(有就逐个点名 + 原始错误);
382   -- 规划记录路径 `workspace/workgraph-plan-<taskId>.md`。
  393 +- 规划记录的绝对路径(`<项目根>/workgraph-plan-<taskId>.md`;没分到项目根就把记录写在正文里)。
  394 +
  395 +**不要报 `workGraphId`**——图 id 是引擎落库时才生成的,你手上没有,编一个只会让引擎去查一个不存在的图,
  396 +白烧一轮(见 Step 7 那张表最后一行)。
383 397  
384   -**此后你不再工作。** 图交出去就归引擎执行,你不推进节点、不看结果、不改图。
  398 +**此后你不再工作。** 图交出去就归引擎落库 + 执行,你不推进节点、不看结果、不改图。
385 399 下一轮如果这个 org node 被打回来,引擎会**带着 `remediation_note` 重新调你一次**——那是新的一轮 Step 1。
386 400  
387 401 ---
... ... @@ -611,14 +625,19 @@ agent-creator digester evolver evolver-youtube-crawler hunter innovato
611 625 6. 我有没有在某个节点里写了「先 research 再 plan 再 execute 再 verify」?
612 626 有的话立刻拆开或删掉——两层 Context Flush 会互相清对方的上下文。
613 627 7. 一个节点是不是塞了两件事?每个节点我都能用一句话说清「做完什么算成功」吗?
614   -8. workspace/workgraph-plan-<taskId>.md 写了吗?七节写全了吗,还是只贴了张图?
615   -9. **【提交前最后一道闸】我把图里每一个 systemAgentId 都 hire 过一遍了吗?**
616   - 我是**先雇完再提交**的,还是"先提交试试"?——反了必挂,而且挂在我下班之后。
  628 +8. 规划记录写了吗?七节写全了吗,还是只贴了张图?
  629 + 它落在**项目根**里(绝对路径)还是被我写成了相对路径 workspace/...(那是越界)?
  630 +9. **【交回图前最后一道闸】我把图里每一个 systemAgentId 都 hire 过一遍了吗?**
  631 + 我是**先雇完再交图**的,还是"先交出去试试"?——反了必挂,而且挂在我下班之后。
617 632 `ls data/companies/<公司ID>/agents/` 我真跑了吗?图里的本体在输出里**一个不少**吗?
618 633 有 already_exists 吗?——那是正常的幂等结果,我没有把它当错误停下来吧?
619   - 有没雇上的吗?——我是按分级表换掉了它,还是明知没雇上还硬提交?
620   -10. POST 回来 400 了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再重试,还是原样重发?
  634 + 有没雇上的吗?——我是按分级表换掉了它,还是明知没雇上还硬交回?
  635 +9bis. **我是不是又想去 POST 一个落库端点?** 没有那个端点(Step 7 第一段)。
  636 + 图放在这一轮回复的 json 围栏块里(或一个绝对路径的 .json 文件),严格 JSON、有 nodes 数组、
  637 + 全文没有 `wg_` 开头的 12 位十六进制字符串——这三条我逐条看过了吗?
  638 +10. 引擎把 errors 贴回来了吗?我读懂 errors[].rule 是哪一条了吗?我是**改了图**再交,还是原样重贴?
621 639 如果是 rule=reference,我确认过不是"本体没雇上"造成的吗?
622   -11. workGraphId 交回引擎了吗?招聘结果汇报了吗?我是不是画完图就停在这儿了?
  640 + rule=output 的话,是我压根没贴图,还是我的 JSON 带了注释/尾逗号解析不了?
  641 +11. 图和汇报在**同一轮回复**里吗?招聘结果汇报了吗?我是不是画完图就停在这儿了?
623 642 </thought>
624 643 ```
... ...
system/templates/workgraph-planning/tools.md
1 1 # WorkGraph Planning 工具箱
2 2  
3   -你的全部工作é äº”类工具完æˆï¼š**读骨架 → è¯»æœ¬ä½“æ¸…å• â†’ è½ç›˜è§„划记录 → æ‹›è˜ â†’ æäº¤å›¾**。
  3 +你的全部工作é äº”类工具完æˆï¼š**读骨架 → è¯»æœ¬ä½“æ¸…å• â†’ è½ç›˜è§„划记录 → æ‹›è˜ â†’ 交回图**。
4 4 按 `agent.md` 的八步工作æµé¡ºåºä½¿ç”¨ã€‚ä½ **䏿‰§è¡Œå›¾ã€ä¸è¾žäººã€ä¸æ”¹ org graph**。
5 5  
6   -> 🚨 **æ‹›è˜ï¼ˆÂ§5)必须排在æäº¤å›¾ï¼ˆÂ§6)å‰é¢ã€‚** å了会外键挂 + èŠ‚ç‚¹è·‘èµ·æ¥æ‰¾ä¸åˆ°äººï¼Œè¯¦è§ `agent.md` Step 6。
  6 +> 🚨 **交回图(§6)没有端点å¯è°ƒã€‚** è½åº“是引擎的活:它读你回å¤çš„æ­£æ–‡å–图ã€è‡ªå·±æ ¡éªŒã€è‡ªå·±å†™åº“。
  7 +> ä½ åªè´Ÿè´£æŠŠå›¾æ”¾åˆ°å®ƒè®¤å¾—çš„ä½ç½®ï¼ˆÂ§6)。
  8 +
  9 +> 🚨 **æ‹›è˜ï¼ˆÂ§5)必须排在交回图(§6)å‰é¢ã€‚** å了会外键挂 + èŠ‚ç‚¹è·‘èµ·æ¥æ‰¾ä¸åˆ°äººï¼Œè¯¦è§ `agent.md` Step 6。
  10 +
  11 +> 🧭 **路径å£å¾„**:CLI 进程的 cwd 是**你自己的 agent 目录**
  12 +> (`data/companies/<å…¬å¸ID>/agents/workgraph-planning`ï¼‰ï¼Œä¸æ˜¯å¼•擎仓库根。
  13 +> 本页所有仓库相对路径(`system/work-graph-templates/`ã€`bin/lemon.js`ã€`data/companies/...`)
  14 +> 都è¦è‡ªå·±æ‹¼ä¸Šå¼•擎仓库根的ç»å¯¹è·¯å¾„å†ç”¨â€”—那个ç»å¯¹è·¯å¾„在首轮消æ¯çš„ã€Œå†™å…¥è¾¹ç•Œã€æ®µé‡Œã€‚读å…è®¸ï¼Œå†™ç¦æ­¢ã€‚
7 15  
8 16 > 🚨 **å‘ JSON çš„é“律(先读这æ¡ï¼Œå¦åˆ™ä½ ä¼šé™·å…¥æ­»å¾ªçŽ¯ï¼‰**
9 17 > 本环境的工具调用在解æžå¸¦å¤šé‡åŒå¼•å·çš„ bash 命令时会崩溃,导致你åå¤"修格å¼"直到进程被æ€ã€‚
10   -> **任何带 JSON body 的请求,一律先把紧凑å•行 JSON 写进 `/tmp/*.json` 文件,å†ç”¨ `curl -d @文件` å‘é€ã€‚**
11   -> JSON 里ä¸è®¸å‡ºçŽ°çœŸå®žæ¢è¡Œç¬¦ï¼Œä¸è®¸å†…è”在 `command` 傿•°é‡Œã€‚
  18 +> **任何带 JSON body 的请求(现在åªå‰©æ‹›è˜é‚£ä¸€æ¡ï¼‰ï¼Œä¸€å¾‹å…ˆæŠŠç´§å‡‘å•行 JSON 写进 `/tmp/*.json` 文件,
  19 +> å†ç”¨ `curl -d @文件` å‘é€ã€‚** JSON 里ä¸è®¸å‡ºçŽ°çœŸå®žæ¢è¡Œç¬¦ï¼Œä¸è®¸å†…è”在 `command` 傿•°é‡Œã€‚
12 20  
13 21 ---
14 22  
... ... @@ -63,15 +71,14 @@ ls -R data/companies/&lt;å…¬å¸ID&gt;/runs/&lt;rootRunId&gt;/artifacts/
63 71  
64 72 ## 4. è½ç›˜è§„划记录(Step 5,别忘)
65 73  
66   -```bash
67   -mkdir -p workspace
68   -```
69   -
70   -ç„¶åŽç”¨æ–‡ä»¶å†™å…¥å·¥å…·å†™ `workspace/workgraph-plan-<taskId>.md`ï¼Œä¸ƒèŠ‚ç»“æž„è§ `agent.md` Step 5
71   -(第 7 节「招è˜ç»“æžœã€åœ¨ §5 招完人之åŽå›žå¡«ï¼‰ã€‚
  74 +用文件写入工具写 `<项目根>/workgraph-plan-<taskId>.md`(**ç»å¯¹è·¯å¾„**,项目根由首轮消æ¯çš„
  75 +ã€Œå†™å…¥è¾¹ç•Œã€æ®µç»™å‡ºï¼›æ²¡åˆ†åˆ°é¡¹ç›®æ ¹å°±ä¸€ä¸ªæ–‡ä»¶éƒ½ä¸è½ï¼ŒæŠŠè®°å½•å†™è¿›å›žå¤æ­£æ–‡ï¼‰ã€‚
  76 +ä¸ƒèŠ‚ç»“æž„è§ `agent.md` Step 5(第 7 节「招è˜ç»“æžœã€åœ¨ §5 招完人之åŽå›žå¡«ï¼‰ã€‚
72 77 **图 + ç†ç”± + 五项校验自查**,三样缺一ä¸å¯â€”—这是人类事åŽå¤æ ¸ä½ åˆ¤æ–­åŠ›çš„å”¯ä¸€çº¿ç´¢ã€‚
73 78  
74   -## 5. æ‹›è˜ï¼ˆStep 6)—— **必须在æäº¤å›¾ä¹‹å‰è·‘完**
  79 +âš ï¸ åˆ«å†™ç›¸å¯¹è·¯å¾„ `workspace/...`:它会è½è¿›å¼•擎æºç ä»“åº“ï¼Œæ­£æ˜¯è¾¹ç•Œæ®µç¦æ­¢å†™çš„ä½ç½®ã€‚
  80 +
  81 +## 5. æ‹›è˜ï¼ˆStep 6)—— **必须在交回图之å‰è·‘完**
75 82  
76 83 对图里 `nodes[].systemAgentId` çš„**全集去é‡**,é€ä¸ªæ‰§è¡Œï¼š
77 84  
... ... @@ -113,38 +120,27 @@ curl -s -X POST http://localhost:3010/api/company/&lt;å…¬å¸ID&gt;/hire \
113 120 ls data/companies/<å…¬å¸ID>/agents/
114 121 ```
115 122  
116   -## 6. æäº¤å›¾ï¼ˆStep 7)
  123 +## 6. 交回图(Step 7)—— **没有端点,引擎读你的回å¤**
117 124  
118   -**先写文件**(用文件写入工具写 `/tmp/workgraph-<taskId>.json`,å•行紧凑 JSON):
  125 +**没有 POST,没有 curl,没有 sqlite。** 把图**è´´åœ¨è¿™ä¸€è½®å›žå¤æ­£æ–‡çš„一个 json å›´æ å—里**(三个åå¼•å· + `json`)
  126 +(或写æˆä¸€ä¸ª `.json` 文件ã€åœ¨å›žå¤é‡Œç»™å‡º**ç»å¯¹è·¯å¾„**),引擎自己å–å‡ºæ¥æ ¡éªŒ + è½åº“。
119 127  
120   -```jsonc
121   -{"ownerOrgNodeId":12,"taskId":"T-xxx",
122   - "nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},
123   - {"nodeName":"org-coder-test","systemAgentId":"unit-tester"},
124   - {"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],
125   - "edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},
126   - {"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]},
127   - {"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},
128   - {"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]}
  128 +```json
  129 +{"nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},{"nodeName":"org-coder-test","systemAgentId":"unit-tester"},{"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],"edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},{"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]},{"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},{"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]}
129 130 ```
130 131  
131 132 > `produces` / `needs` 就是 §6bis 那两个键。**forward 边上少了它们,下游节点的æç¤ºè¯é‡Œ
132 133 > 就没有上游产物的路径** —— 先读 §6bis å†å†™è¿™ä»½ JSON。
133 134  
134   -**å†å‘é€**:
135   -
136   -```bash
137   -curl -s -X POST http://localhost:3010/api/company/<å…¬å¸ID>/work-graphs \
138   - -H "Content-Type: application/json" \
139   - -d @/tmp/workgraph-<taskId>.json
140   -```
  135 +引擎å–图的死规矩(完整版在 `agent.md` Step 7):**严格 JSON**(注释 / å°¾é€—å· / çœç•¥å·ä¸€å¾‹è§£æžå¤±è´¥ï¼Œ
  136 +æ•´å—当没写)· é¡¶å±‚è¦æœ‰ **`nodes` 数组** · 文件路径必须**ç»å¯¹** ·
  137 +回å¤é‡Œ**ä¸è®¸å‡ºçް `wg_` + 12 ä½å六进制**的字符串(会被当æˆ"ä½ å·²è½åº“的图 id",查ä¸åˆ°å°±ç™½çƒ§ä¸€è½®ï¼‰ã€‚
141 138  
142 139 字段约定:
143 140  
144 141 | 字段 | 说明 |
145 142 |---|---|
146   -| `ownerOrgNodeId` | 宿主 org node 在 `org_graph_nodes` 里的 id,首轮消æ¯ä¼šç»™ |
147   -| `goalId` / `taskId` / `okrNodeId` | 三者**基数ä¸åŒã€ä¸å¯æ··ç”¨**(27-NAMING §四):goal 唯一ã€task 多个ã€okr æ˜¯é•¿å‘¨æœŸåº¦é‡æ ‘节点 |
  143 +| `nodes` / `edges` | **引擎åªè¯»è¿™ä¸¤ä¸ªé”®**。`ownerOrgNodeId` / `goalId` / `taskId` / `okrNodeId` 由引擎从 run 上下文自己填,写在 JSON 里ä¸ç”Ÿæ•ˆï¼ˆè¦ç•™ç—•写进规划记录第 4 节) |
148 144 | `nodes[].nodeName` | 必填,**图内唯一**,带宿主 org node å…¨åå‰ç¼€ |
149 145 | `nodes[].systemAgentId` | 引用的**本体**,**ä¸å¸¦**å‰ç¼€ï¼›çœç•¥æ—¶é»˜è®¤ç­‰äºŽ `nodeName`(几乎总该显å¼å†™ï¼‰ |
150 146 | `nodes[].nodeKind` | `"agent_call"`(默认å¯çœç•¥ï¼‰\| `"subgraph"`(è¦å†ä¸‹é’»ä¸€å±‚图时æ‰ç”¨ï¼‰ |
... ... @@ -155,14 +151,13 @@ curl -s -X POST http://localhost:3010/api/company/&lt;å…¬å¸ID&gt;/work-graphs \
155 151 | `edges[].produces` | **æ•°æ®å¥‘约**:这æ¡è¾¹çš„**上游**会产出哪些 kindï¼Œäº¤ç»™ä¸‹æ¸¸ã€‚è§ Â§6bis,**forward 边基本都è¦å†™** |
156 152 | `edges[].needs` | **æ•°æ®å¥‘约**:这æ¡è¾¹çš„**下游**è¦åƒå“ªäº› kind,而产出方**䏿˜¯**ç´§é‚»ä¸Šæ¸¸ï¼ˆè·¨èŠ‚ç‚¹ä¸­ç»§ï¼‰ã€‚è§ Â§6bis |
157 153  
158   -**400 的处ç†è§ `agent.md` Step 7 的规则对照表**:`errors[].rule` å–值为
159   -`acyclic` / `single-entry` / `reachable` / `backward-labeled` / **`fail-fallback-edge`** /
160   -**`data-contract`** / `schema` / `reference`。
161   -读懂 → 改图 → é‡è¯•,最多 5 轮。
  154 +**图没过校验时引擎把 `errors` 贴进下一轮æç¤ºè¯**ï¼ˆä¸æ˜¯ HTTP 400,没有å“应体å¯è¯»ï¼‰ã€‚
  155 +`errors[].rule` å–值为 **`output`**(压根没找到图)/ `acyclic` / `single-entry` / `reachable` /
  156 +`backward-labeled` / **`fail-fallback-edge`** / **`data-contract`** / `schema` / `reference`,
  157 +逿¡æ€Žä¹ˆä¿®è§ `agent.md` Step 7 的规则对照表。读懂 → 改图 → é‡äº¤ï¼Œ**引擎åªç»™ 3 è½®**。
162 158  
163   -**若端点返回 404**(引擎侧路由尚未上线):ä¸è¦é‡è¯•ã€ä¸è¦ç»•é“写数æ®åº“。
164   -把åŒä¸€ä»½ JSON 原样写到 `workspace/workgraph-<taskId>.json`,在 Step 8 汇报这个路径。
165   -**èµ°é™çº§è·¯å¾„ä¸è±å… §5 的招è˜**——图迟早è¦è½åº“,人没雇é½å®ƒè½åº“时照样外键挂。
  159 +**交回图ä¸è±å… §5 的招è˜**——引擎在你这一轮回å¤ç»“æŸçš„那一刻就è½åº“,人没雇é½ç…§æ ·å¤–键挂,
  160 +而那时你已ç»ä¸‹ç­äº†ã€‚
166 161  
167 162 ## 6bis. 边上的数æ®å¥‘约(`produces` / `needs`)—— **ä¸å†™å°±ç­‰äºŽä¸‹æ¸¸æ‹¿ä¸åˆ°è¾“å…¥** 🚨
168 163  
... ... @@ -238,18 +233,19 @@ org graph 或 `artifacts/` ç»™çš„ï¼‰ã€‚å®ƒåŒæ—¶æ˜¯é€ƒç”Ÿå£â€”—è§ä¸‹é¢ç¬¬å…
238 233 它们已ç»åœ¨ `artifacts/` 里时你ä¸å¿…也ä¸è¯¥æ²¿è¾¹å£°æ˜Žï¼›åªæœ‰å½“**本图里æŸä¸ªèŠ‚ç‚¹è‡ªå·±äº§å‡º**它ã€
239 234 å†äº¤ç»™ä¸‹æ¸¸æ—¶æ‰å†™ `produces`。
240 235  
241   -## 7. æäº¤å‰è‡ªæŸ¥äº”项校验(å¯é€‰ä½†å¼ºçƒˆå»ºè®®ï¼Œçœä¸€è½® 400)
  236 +## 7. 交回å‰è‡ªæŸ¥äº”项校验(å¯é€‰ä½†å¼ºçƒˆå»ºè®®ï¼Œå¼•擎åªç»™ 3 轮)
242 237  
243   -引擎用的就是这个函数,你å¯ä»¥å…ˆåœ¨æœ¬åœ°è·‘一é:
  238 +引擎用的就是这个函数,你å¯ä»¥å…ˆåœ¨æœ¬åœ°è·‘一é(`<引擎仓库根>` ç”¨ã€Œå†™å…¥è¾¹ç•Œã€æ®µç»™çš„那个ç»å¯¹è·¯å¾„,
  239 +`require` 也è¦ç”¨ç»å¯¹è·¯å¾„ —— ä½ çš„ cwd ä¸åœ¨ä»“库根,写 `./server/...` 会 `Cannot find module`):
244 240  
245 241 ```bash
246 242 node -e "
247   -const {validateWorkGraph}=require('./server/services/work-graph-db.js');
248   -const t=JSON.parse(require('fs').readFileSync('/tmp/workgraph-<taskId>.json','utf-8'));
  243 +const {validateWorkGraph}=require('<引擎仓库根>/server/services/work-graph-db.js');
  244 +const t=JSON.parse(require('fs').readFileSync('<项目根>/workgraph-<taskId>.json','utf-8'));
249 245 console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));"
250 246 ```
251 247  
252   -`{"ok":true,"errors":[]}` æ‰æäº¤ã€‚è¿™ä¸ªå‡½æ•°**连第六项 `data-contract` 一起查**
  248 +`{"ok":true,"errors":[]}` æ‰äº¤å›žã€‚这个函数**连第六项 `data-contract` 一起查**
253 249 (`produces` / `needs` çš„å–å€¼åŸŸã€æœ‰æ²¡æœ‰å†™åˆ° backward 边上ã€`needs` çš„ kind 有没有产出方),
254 250 所以 §6bis 那几æ¡ä¹Ÿåœ¨è¿™ä¸€æ­¥å°±èƒ½è‡ªæŸ¥å‡ºæ¥ã€‚
255 251  
... ... @@ -265,11 +261,12 @@ console.log(JSON.stringify(validateWorkGraph(t.nodes,t.edges),null,1));&quot;
265 261  
266 262 | 别用 | 为什么 |
267 263 |---|---|
  264 +| `POST /api/company/:id/work-graphs` | **NO-SUCH-ENDPOINT — 这个端点ä¸å­˜åœ¨ï¼Œä»Žæ¥æ²¡æœ‰å®žçŽ°è¿‡ï¼Œä¸è¦è°ƒç”¨ã€‚** 从å‰è¿™ä»½æ¨¡æ¿æ•™è¿‡å®ƒï¼Œå®žæµ‹æ¯æ¬¡çœŸè·‘都åƒä¸€ä¸ª 404ã€ç™½çƒ§ä¸€è½®è°ƒç”¨ã€‚è½åº“归引擎(§6),è°ä¹Ÿä¸ç”¨ POST 图 |
268 265 | `POST/PATCH /api/company/:id/org-graph` | org graph 是上一层,归 `orggraph-planning` / HR。你åªç”» work graph |
269 266 | `node bin/lemon.js company fire` | ä½ åªæ‹›ä¸è¾žã€‚图用完å³å½’档,但雇进æ¥çš„æœ¬ä½“留给åŽç»­ä»»åŠ¡å¤ç”¨ï¼ˆä¸‹æ¬¡ `hire` 会返回 `already_exists`) |
270 267 | `node bin/lemon.js template create` / `template train` | 造本体ã€è®­æœ¬ä½“是 `agent-creator` / `innovator` çš„æ´»ã€‚ä½ åª `hire`,模æ¿åº“里没有的角色 `hire` 会自动去继承+触å‘它们 |
271   -| 手工 `mkdir` / `cp` 出 `data/companies/<å…¬å¸ID>/agents/<本体å>/` | 看ç€åƒæ‹›è˜ï¼Œå…¶å®ž**ä¸å†™ `system_agents` 表**,图æäº¤æ—¶ç…§æ · `rule=reference` 被打回。åªèƒ½èµ° `hire` |
  268 +| 手工 `mkdir` / `cp` 出 `data/companies/<å…¬å¸ID>/agents/<本体å>/` | 看ç€åƒæ‹›è˜ï¼Œå…¶å®ž**ä¸å†™ `system_agents` 表**,引擎è½åº“æ—¶ç…§æ · `rule=reference` 被打回。åªèƒ½èµ° `hire` |
272 269 | 直接写 `data/` 下的 sqlite / json | ç»•è¿‡äº”é¡¹æ ¡éªŒå°±æ˜¯æŠŠéžæ³•图塞进系统 |
273   -| `POST /api/graph-runs/:id/{start,next,...}` | 执行是引擎的活。你交出 `workGraphId` å°±ä¸‹ç­ |
  270 +| `POST /api/graph-runs/:id/{start,next,...}` | 执行是引擎的活。你把图交出去(§6ï¼‰å°±ä¸‹ç­ |
274 271 | 对已è½åº“çš„ work graph åšä»»ä½•修改 | work graph 生命周期是**一次性**çš„ï¼Œè¦æ”¹å°±é‡æ–°è§„划一张,没有增é‡è¡¥ä¸ |
275 272 | `task transition` | **ç»å¯¹ä¸è¦**。它是 base 四段å¼çš„æ¢é˜¶æ®µå‘½ä»¤ï¼Œä¸Žå›¾æµè½¬æ˜¯ä¸¤å±‚çŠ¶æ€æœºï¼ˆ`agent.md` 约æŸå››ï¼‰ |
... ...
system/work-graph-templates/README.md
... ... @@ -133,12 +133,16 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph
133 133 { "nodeName": "benchmark-validator", "systemAgentId": "benchmark-validator",
134 134 "readsArtifacts": ["goal-benchmark.json"] } // 可选,声明它读哪些共享产物
135 135 ],
  136 + "dataContractNotes": "……", // 可选,给规划 agent 看的取舍说明:这张图为什么这么声明数据契约
136 137 "edges": [
137   - { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success" },
  138 + { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success",
  139 + "produces": ["brd"] }, // 数据契约:上游产出什么,交给下游
  140 + { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success",
  141 + "produces": ["sdd"], "needs": ["prd"] }, // needs = 下游要吃、但产出方不是紧邻上游
138 142 { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail",
139 143 "failReason": "input_underspecified", "edgeKind": "backward" },
140 144 { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail",
141   - "edgeKind": "backward" } // 无 failReason = 兜底边
  145 + "edgeKind": "backward" } // 无 failReason = 兜底边;backward 边一律不声明契约
142 146 ]
143 147 }
144 148 ```
... ... @@ -152,6 +156,46 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph
152 156 只有确实要再下钻一层图的节点才写 `subgraph`。
153 157 - `writesArtifacts` / `readsArtifacts`:可选声明,对应 §四.A.5 的共享产物区读写权。
154 158 写 `artifacts/` 是特权动作,只有被显式标记的节点才有,防止任意节点往共享区乱写。
  159 +- `produces` / `needs`:**边上的数据契约**,见下一节。四张骨架的每条 forward 边都必须有其中之一。
  160 +
  161 +## `produces` / `needs`:边只带控制流的话,图就不传数据
  162 +
  163 +`condition` / `edgeKind` / `failReason` 表达的全是**执行流**。下游节点的提示词里能不能出现
  164 +「上游产物在这个绝对路径」,只取决于边上的数据契约声明——第四次真 CLI 冒烟里
  165 +`org-planner-prd` 的归因原话是「不是上游没产出,是它的产出结构上到不了我手上」,
  166 +根因就是这条边上什么都没写。
  167 +
  168 +这个声明有**两个**读回方,都在 `server/services/graph-engine.js`:
  169 +
  170 +| 环 | 谁读 | 读到之后干什么 |
  171 +|---|---|---|
  172 +| 输入侧 | `describeInputs` → `inboundContracts('work', def.edges, defNodeId)` | 按声明的 kind 去产物索引查实例,把**绝对路径**拼进下游节点的提示词;声明了却查不到就显式标 ⚠️ |
  173 +| 产出侧 | `indexNodeOutputs` → 读本节点**出边**上的契约 | 用声明的 kind 给这个节点刚落盘的文件定 kind(文件名认不出来时靠声明一对一认领),否则一律记成 `artifact`,下游按 kind 就查不到 |
  174 +
  175 +写法与规则(权威说明在 `system/templates/workgraph-planning/tools.md` §6bis,这里只记要点):
  176 +
  177 +- kind 取值域只有 7 个:`brd` / `prd` / `sdd` / `acceptance` / `contract` / `artifact` / `log`,
  178 + 自创取值会被 `rule=schema` 打回。
  179 +- `produces` 写在**产出方的出边**上(绝大多数情况只用它);`needs` 写在**消费方的入边**上,
  180 + 仅当产出方**不是紧邻上游**(跨节点中继)时才用。
  181 +- **`backward` 边一律不声明**(`rule=schema`):引擎解析输入只看 forward 入边,
  182 + 写在回退边上等于没写,而「看起来写了」比「没写」更难排查。
  183 +- **规则 6 `data-contract`**:声明要吃的 kind(`needs`,`required` 不为 false)
  184 + 在图上必须有产出方,否则整张图被拒。产出方确实不在本图(上一层 org graph 或 `artifacts/` 给的)时,
  185 + 写成 `"needs":[{"kind":"artifact","required":false}]` —— 这是唯一的逃生口,
  186 + `validator.json` 用的就是它。
  187 +- **任务级共享的 `acceptance` / `contract` 不要沿边声明**:引擎按 task + kind 自动递给每个节点
  188 + (`graph-engine` 的 `TASK_SHARED_KINDS`),不受拓扑相邻性限制。只有当**本图里某个节点自己产出**它、
  189 + 再交给本图的下游时才写 `produces`(`planner.json` 的 `acceptance` 就是这种情形)。
  190 +
  191 +🛡️ **防线**:`server/tests/unit/work-graph-skeletons.test.js` 逐张骨架查四件事——
  192 +过 `validateWorkGraph`(含规则 6)、**每条** forward 边都有非空声明、
  193 +`createWorkGraph` 落库后 `graph_edge_contracts` 的 kind/role/required 与 JSON 逐条一致、
  194 +每个非入口节点的 `inboundContracts` 非空。改本目录任何 JSON 后跑:
  195 +
  196 +```bash
  197 +npx vitest run server/tests/unit/work-graph-skeletons.test.js
  198 +```
155 199  
156 200 ## 加一个新模板
157 201  
... ... @@ -172,6 +216,7 @@ org graph 模板里**首尾同名**(27-NAMING §3.1),它们的 work graph
172 216 | 3 | **全可达** | 从入口沿 forward 边 BFS,必须覆盖 `nodes` 里每一个节点 |
173 217 | 4 | **回退边显式标注** | 任何 `to` 在拓扑序上早于 `from` 的边,必须带 `"edgeKind": "backward"` |
174 218 | 5 | **fail 兜底边** | 有 `fail` 出边的节点,其中必须至少有一条不带 `failReason` |
  219 + | 6 | **声明要吃的 kind 有产出方** | 每条 `needs`(未标 `required:false`)的 kind,图上必须有某条出边 `produces` 它 |
175 220  
176 221 7. 自验:直接拿引擎真正用的那套校验跑一遍本目录全部模板(占位符不影响校验,它只做名字匹配):
177 222  
... ...
system/work-graph-templates/executor-fanout.json
... ... @@ -3,6 +3,7 @@
3 3 "label": "执行型 org node 的 work graph(fan-out / fan-in 起手式)",
4 4 "whenToUse": "宿主 org node 是执行类,且这次任务能拆成多个互不依赖、可并行推进的分支时用本骨架——27-REQ R-6 第 6 步描述的就是它:拆分 → 并行实现 + 各自自测(自测失败只回退本分支)→ fan-in 汇合整合 → 集成测试 → 本层验收。典型信号词:多个模块、多个页面、多个接口、可以同时做。汇合节点有多条入边,引擎按「全部入边条件都满足才置 ready」处理(27-P3/P4 §四.D.1)。分支数按实际任务增减,本文件给的是 2 条;任务只有一个交付物就别用它,用 executor.json。",
5 5 "ownerOrgNode": "{{orgNode}}",
  6 + "dataContractNotes": "拆分节点的产物声明成 contract(分支间接口契约)而不是 artifact:两条分支各自的交付物已经占了 artifact 这个 kind,而产物索引按 task + kind 查、同一 kind 只把最新那份递给下游,拆分方案写成 artifact 会被先跑完的分支产物顶掉。代价是 contract 属任务级共享 kind,本来就会自动递给每个节点,所以这两条声明的实际作用偏诊断(声明了却查不到实例时,提示词会显式标 ⚠️,等于当场暴露「拆分节点没产出接口契约」)。fan-in 的已知上限:merge 的两条入边各自 needs artifact,但索引按 kind 只回最新一份,merge 提示词里只会出现一条路径——两个分支的产物都要过目时,merge 仍得按提示词给的节点目录自己找齐。升级路径是让产物索引支持按 fromNodeId 过滤(inboundContracts 已经带回 fromNodeId,describeInputs 还没用它)。",
6 7 "nodes": [
7 8 { "nodeName": "{{orgNode}}-split", "systemAgentId": "{{splitAgent}}" },
8 9 { "nodeName": "{{orgNode}}-impl-a", "systemAgentId": "{{implAgent}}" },
... ... @@ -14,14 +15,14 @@
14 15 { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" }
15 16 ],
16 17 "edges": [
17   - { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-a", "condition": "success" },
18   - { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success" },
19   - { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success" },
20   - { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success" },
21   - { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success" },
22   - { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success" },
23   - { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success" },
24   - { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success" },
  18 + { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-a", "condition": "success", "produces": ["contract"] },
  19 + { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success", "produces": ["contract"] },
  20 + { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success", "produces": ["artifact"] },
  21 + { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success", "produces": ["artifact"] },
  22 + { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] },
  23 + { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] },
  24 + { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success", "produces": ["artifact"] },
  25 + { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] },
25 26 { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" },
26 27 { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" },
27 28 { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" },
... ...
system/work-graph-templates/executor.json
... ... @@ -3,14 +3,15 @@
3 3 "label": "执行型 org node 的 work graph(串行起手式)",
4 4 "whenToUse": "宿主 org node 的职能是「按上游给的约束做出实际产物」,且这次任务小到不值得拆并行分支时用本骨架——典型是 org-coder / org-writer / org-monitor。典型信号词:实现、写、改、跑、部署,且任务是单一交付物。三节点:做 → 自测 → 本层验收,自测和验收失败都退回实现节点。任务能明显拆成多个互不依赖的分支时改用 executor-fanout.json;宿主是规划类节点用 planner.json;宿主是 org-validator 用 validator.json。",
5 5 "ownerOrgNode": "{{orgNode}}",
  6 + "dataContractNotes": "交付物走 artifact,自测记录走 log。validator 既要看测试记录(紧邻上游 produces log)也要看被验的交付物本身,而交付物的产出方隔了一个节点,所以用 needs artifact 中继——这条就是 tools.md §6bis 举的那个例子。上游给的 sdd 与验收标准 acceptance 都由别的 org node 的图产出,属任务级共享,引擎按 task + kind 自动递,不在本图沿边声明。",
6 7 "nodes": [
7 8 { "nodeName": "{{orgNode}}-impl", "systemAgentId": "{{implAgent}}" },
8 9 { "nodeName": "{{orgNode}}-test", "systemAgentId": "{{testAgent}}" },
9 10 { "nodeName": "{{orgNode}}-validator", "systemAgentId": "{{validatorAgent}}" }
10 11 ],
11 12 "edges": [
12   - { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success" },
13   - { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success" },
  13 + { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success", "produces": ["artifact"] },
  14 + { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] },
14 15 { "from": "{{orgNode}}-test", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" },
15 16 { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" }
16 17 ]
... ...
system/work-graph-templates/planner.json
... ... @@ -3,6 +3,7 @@
3 3 "label": "规划型 org node 的 work graph",
4 4 "whenToUse": "宿主 org node 的职能是「把一句人类需求变成下游能照着干的约束」时用本骨架——典型是 org-planner,也适用于任何产出物是文档/规格/方案而不是可运行产物的节点。典型信号词:需求、规格、方案、拆解、定验收标准。本骨架最大的特点是它负责产出这次 task-goal 的验收契约(goal-benchmark / goal-test-data / goal-test-script 三选一或多选,动态选入,见 27-E2E §十一),这些产物写进 root run 的 artifacts/ 共享区,消费者是另一个 org node(org-validator)。若宿主节点是执行类(写代码/写内容/做运维)用 executor.json;若宿主节点是 org-validator 用 validator.json。",
5 5 "ownerOrgNode": "org-planner",
  6 + "dataContractNotes": "三份文档各由自己那一步 produces(brd / prd / sdd)。goal-benchmark 的主输入是 prd + sdd(见 goal-benchmark/agent.md「输入」),prd 的产出方隔了一个节点,所以用 needs 中继。planner-validator 的职责 A 要审三份产物是否完整、彼此是否矛盾,三个 kind 全部 needs。acceptance 写 produces 是因为它的产出方(goal-benchmark)就在本图里、消费方(planner-validator)也在本图里——这是任务级共享 kind 唯一该沿边声明的情形;反过来,别的图产出的 acceptance / contract 一律不声明,引擎按 task + kind 自动递(graph-engine 的 TASK_SHARED_KINDS),重复声明只是给提示词加一行噪声。contract.json 也不声明:产出侧按文件名就能认成 kind=contract。",
6 7 "nodes": [
7 8 { "nodeName": "org-planner-brd", "systemAgentId": "brd-planning" },
8 9 { "nodeName": "org-planner-prd", "systemAgentId": "prd-planning" },
... ... @@ -11,10 +12,10 @@
11 12 { "nodeName": "org-planner-validator", "systemAgentId": "planner-validator" }
12 13 ],
13 14 "edges": [
14   - { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success" },
15   - { "from": "org-planner-prd", "to": "org-planner-sdd", "condition": "success" },
16   - { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success" },
17   - { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success" },
  15 + { "from": "org-planner-brd", "to": "org-planner-prd", "condition": "success", "produces": ["brd"] },
  16 + { "from": "org-planner-prd", "to": "org-planner-sdd", "condition": "success", "produces": ["prd"] },
  17 + { "from": "org-planner-sdd", "to": "org-planner-benchmark", "condition": "success", "produces": ["sdd"], "needs": ["prd"] },
  18 + { "from": "org-planner-benchmark", "to": "org-planner-validator", "condition": "success", "produces": ["acceptance"], "needs": ["brd", "prd", "sdd"] },
18 19 { "from": "org-planner-validator", "to": "org-planner-prd", "condition": "fail", "failReason": "input_underspecified", "edgeKind": "backward" },
19 20 { "from": "org-planner-validator", "to": "org-planner-sdd", "condition": "fail", "edgeKind": "backward" }
20 21 ]
... ...
system/work-graph-templates/validator.json
... ... @@ -3,14 +3,15 @@
3 3 "label": "org-validator 的 work graph(归因排第一位)",
4 4 "whenToUse": "宿主 org node 是 org-validator(org graph 末端那个「整个任务达成了吗」的节点)时**必须**用本骨架。它与另外两个骨架的硬性区别:第一个节点必须是归因节点 validator-triager——先归因、再决定后面怎么验/怎么退(27-P3/P4 §四.D.3)。它产出的 fail_reason + remediation_note 会透传回 org 层,成为 org graph 选走哪条 fail 边的唯一依据(§四.D.0 三值透传 / §四.D.2.2 选边)。少了这个节点,org 层失败后不知道该退回 org-planner 还是 org-coder。宿主不是 org-validator 就别用本骨架。",
5 5 "ownerOrgNode": "org-validator",
  6 + "dataContractNotes": "本图两条 forward 边只有 needs 没有 produces,这是刻意的:归因节点的判词写在 attempt-{n}.json 里、由引擎自己读(readNodeProduct),产出侧刻意把这个文件名排除在产物索引之外,benchmark-validator 的评分同理只喂给 goal-contract.js 的执行器;写 produces 会让每一轮都出现一条「声明了会产出但索引里查不到实例」的 ⚠️ 假警报。被验产物由上一层 org graph 的执行节点(org-coder 等)产出、不在本图,所以 needs 标 required:false——这既是规则 6(data-contract)的逃生口,也是本骨架能过校验的原因;产物索引按 company + task 查、不按图,路径照样递得到。验收标准 acceptance 由 org-planner 那张图产出,属任务级共享,引擎自动递(节点上的 readsArtifacts 已经把它写明),不沿边声明。",
6 7 "nodes": [
7 8 { "nodeName": "validator-triager", "systemAgentId": "validator-triager" },
8 9 { "nodeName": "benchmark-validator", "systemAgentId": "benchmark-validator", "readsArtifacts": ["goal-benchmark.json"] },
9 10 { "nodeName": "testdata-validator", "systemAgentId": "testdata-validator", "readsArtifacts": ["goal-test-data/"] }
10 11 ],
11 12 "edges": [
12   - { "from": "validator-triager", "to": "benchmark-validator", "condition": "success" },
13   - { "from": "benchmark-validator", "to": "testdata-validator", "condition": "success" },
  13 + { "from": "validator-triager", "to": "benchmark-validator", "condition": "success", "needs": [{ "kind": "artifact", "required": false }] },
  14 + { "from": "benchmark-validator", "to": "testdata-validator", "condition": "success", "needs": [{ "kind": "artifact", "required": false }] },
14 15 { "from": "benchmark-validator", "to": "validator-triager", "condition": "fail", "edgeKind": "backward" },
15 16 { "from": "testdata-validator", "to": "validator-triager", "condition": "fail", "edgeKind": "backward" }
16 17 ]
... ...