Commit 3943354b78dc718cd3e258355af661bba3cc4123

Authored by yibo
1 parent ef20ceb6

feat(task-decomposition): 46号文档 Phase 2(收窄版)经验检索 + 按需 deep research

subtask-planning 改成两段式:先查 experience-lookup(扫已雇 agent 的 dream
沉淀经验,关键词打分)拼进提示词,agent 自评 confidence;经验为空或
confidence low 时触发 deep-research(图外直接调用 org-hunt 轻量提示词,
不套用其训练场景三件套契约,默认关闭,需 ENABLE_DEEP_RESEARCH=1)。
reasoning_source/experience_refs/research_used 增补进 subtask-plan.json,
向后兼容旧注入桩。

范围收窄:不做"历史同类 goal 检索"(需要语义检索基础设施);Phase 3-5
(任务池认领/子任务预算/看板 UI)涉及核心调度引擎范式级改动,另起设计。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H85SS5hVC4u1PbVhjmrhH8
docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md
1 # 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源 1 # 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源
2 2
3 -> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-19.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。Phase 2-5 尚未开工 3 +> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-9.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。**Phase 2 已实现(收窄版,2026-09-02,见「Phase 2 实现记录」)**:经验检索(新查询能力,扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭),单测覆盖,同样还没做真实端到端回归。"历史同类 goal 检索"这条来源本轮明确收窄未做。**Phase 3-5 涉及核心调度引擎范式级改动(任务池认领)+ 全新子任务级预算模型,风险量级不同,本轮不做,另起设计讨论**
4 > **日期**: 2026-09-01 4 > **日期**: 2026-09-01
5 > **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。 5 > **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。
6 > **格式对齐** `27-requirement.md`/`43-task-decomposition-granularity.md`:原话照搬 + 结构化复述,现状调研另起一节。 6 > **格式对齐** `27-requirement.md`/`43-task-decomposition-granularity.md`:原话照搬 + 结构化复述,现状调研另起一节。
@@ -280,3 +280,30 @@ goal 创建 @@ -280,3 +280,30 @@ goal 创建
280 ### 遗留:还没做真实端到端回归 280 ### 遗留:还没做真实端到端回归
281 281
282 设计文档「Phase 1 验收标准」一节列的三条(① dispatch 后 subtask-planning 真的被触发并产出 artifact;② org-planner 展开的子图节点提示词里真的能读到这段参考;③ subtask-planning 没跑完时 org-planner 完全不受影响)——**①③ 已经被集成测试间接覆盖**(用注入桩验证了机制本身是对的),**②的"提示词真的拼上了共享索引这一段"也已经用集成测试直接验证**(`describeInputs` 真实读到 `subtask_plan` 并列进提示词)。**没做的是**:用真实公司、真实 goal、真实 Claude CLI 走一遍全链路,确认 subtask-planning 在真实调用下真的产出有意义的分解(不是机制通不通,是内容有没有用)——考虑到刚踩过的真实 CLI 子进程事故,这次真跑验证需要更谨慎地控制范围(单个任务、有人盯着、跑完立刻检查有没有残留进程),不建议顺手跑,值得单独作为一步来做。 282 设计文档「Phase 1 验收标准」一节列的三条(① dispatch 后 subtask-planning 真的被触发并产出 artifact;② org-planner 展开的子图节点提示词里真的能读到这段参考;③ subtask-planning 没跑完时 org-planner 完全不受影响)——**①③ 已经被集成测试间接覆盖**(用注入桩验证了机制本身是对的),**②的"提示词真的拼上了共享索引这一段"也已经用集成测试直接验证**(`describeInputs` 真实读到 `subtask_plan` 并列进提示词)。**没做的是**:用真实公司、真实 goal、真实 Claude CLI 走一遍全链路,确认 subtask-planning 在真实调用下真的产出有意义的分解(不是机制通不通,是内容有没有用)——考虑到刚踩过的真实 CLI 子进程事故,这次真跑验证需要更谨慎地控制范围(单个任务、有人盯着、跑完立刻检查有没有残留进程),不建议顺手跑,值得单独作为一步来做。
  283 +
  284 +---
  285 +
  286 +## Phase 2 实现记录(2026-09-02,收窄版,代码已实现,未做真实端到端回归)
  287 +
  288 +用户原话要求"把 Phase 2-5 一次性做完"。动手前先做了一轮代码调研,发现文档字面描述掩盖了真实工作量落差:dream(29-DREAM)只**单向写回**经验,没有任何可查询索引;"R-19.3 共享索引"是笔误(实指 `TASK_SHARED_KINDS`/R-9.3,跟经验库无关);org-hunt 只能作为图节点被 `workgraph-planning` 动态选中,没有图外可调用入口;Phase 3(任务池认领)是"改动面最大的一条",要给纯拓扑驱动的 `graph-engine.js` 并行引入认领制调度层;Phase 4 需要全新的子任务级 run + 预算模型;Phase 5 在 3/4 落地前没有真实"可认领池"状态可展示。跟用户对齐后,**这一轮只做 Phase 2(收窄版)**,Phase 3-5 涉及核心调度引擎范式级改动 + 全新预算模型,风险量级不同,另起设计讨论。
  289 +
  290 +### 落地清单
  291 +
  292 +| 内容 | 位置 |
  293 +|---|---|
  294 +| 经验检索(新模块) | `server/services/experience-lookup.js`——`queryExperience({companyId, task})`:查这家公司已雇佣 agent 的 `memory/experience.md`(dream 沉淀),关键词重叠打分(中文用双字滑窗近似分词,不引依赖)。命中为零是真实信号("查不到"),不是异常 |
  295 +| 按需 Deep Research(新模块) | `server/services/deep-research.js`——`triggerDeepResearch({companyId, task, topic})`:图外直接 `AgentManager.callAgent('org-hunt', ...)`,轻量提示词,不套用 org-hunt 训练场景的三件套契约。**默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量,不为 `'1'` 直接返回 `null`,连注入的假实现都不调用) |
  296 +| `subtask-planning.js` 两段式改造 | `defaultPlanSubtasks`:先查经验库拼进提示词 → 调一次 agent,agent 自评 `confidence` → 经验为空或 `confidence:"low"` 就触发调研 → 有结果就带着调研结果再调一次取代第一轮。`reasoning_source`(`model_only`/`memory`/`deep_research`)由服务端根据实际发生的分支算出,不是 agent 自报的。新增更细粒度注入点 `setSubtaskAgentCaller`(仿 `dream-service.setDreamAgentCaller`),能测到这段编排逻辑本身,不只是测"落盘"那一层 |
  297 +| `subtask-plan.json` 增补字段 | `reasoning_source`/`experience_refs`/`research_used`,纯增量、向后兼容旧的注入桩(缺失时兜底 `model_only`/`[]`/`false`) |
  298 +| `system/agents/subtask-planning/agent.md` | 输出契约加 `confidence` 字段(硬约束 9),补充"你可能会看到两种提示词"一节 |
  299 +| 测试 | `experience-lookup.test.js`(6 条)、`deep-research.test.js`(6 条,含"关闭开关时哪怕注入了假实现也不调用"这条最重要的安全阀)、`subtask-planning.test.js` 扩展(`extractConfidence` + `buildSubtaskPlanningPrompt` 的经验/调研小节,7 条新增)、`subtask-planning-materialize.test.js` 扩展(新增字段落盘 2 条 + `defaultPlanSubtasks` 三条分支编排 4 条)。全量单测 1177 条,1175 passed,唯二失败(`.env.example` 覆盖 + 测试 id 稳定性)与本次改动无关、改动前即存在。测试全程 `ps aux` 确认零残留 CLI 子进程 |
  300 +
  301 +### 与原设计的出入
  302 +
  303 +1. **"历史同类 goal 执行记录"这条来源本轮不做**——只做 agent 经验区块检索。原设计的用户原话把"经验库/memory/dream 沉淀"和"历史同类 goal 检索"并列成一条来源,但后者需要语义检索基础设施(当前没有),贸然上关键词匹配对"同类"这种判断噪音会很大。这是范围收窄,不是遗忘,留给以后需要时再评估怎么做语义检索。
  304 +2. **Deep research 复用 org-hunt,但用轻量提示词,不套用它的训练场景三件套契约**(`knowhow_raw.md`/`crawl_config.json`/`seed_data.json` ≥50 条)。原因:那套契约是为 FDE 训练场景校准的,直接套用在"人在等一条 task 入队"这种时间敏感场景会不可控地慢。org-hunt 本身的图节点契约、`deliverable.json` 模板都没动。
  305 +3. **Deep research 触发默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量)。org-hunt 的延迟/成功率在"日常任务规划、时间敏感"场景下从未验证过,且 Phase 1 已经因为一个新触发点疏于隔离出过真事故。这跟 Phase 1 `withSubtaskPlanning` 默认 `false` 是同一个谨慎级别——机制已实现且单测覆盖,但真实调用需要用户手动开关、挑单个任务验证。
  306 +
  307 +### 遗留:还没做真实端到端回归
  308 +
  309 +跟 Phase 1 一样,机制通不通已经用注入桩验证(三条 `reasoning_source` 分支、经验命中/未命中、调研开/关/成功/失败都有独立测试覆盖)。**没做的是**:真实开一次 `ENABLE_DEEP_RESEARCH=1`,挑一个真实任务,确认①经验检索真的能从生产环境的 dream 沉淀里查到有用内容(目前多数本体 dream 还没攒够料,可能长期查不到,这是预期状态不是 bug);②org-hunt 用轻量提示词跑起来的真实延迟、真实调研质量;③两轮调用叠加后的真实 token/时间成本。不在本轮自动测试范围内,需要用户后续单独决定何时验证。
server/services/deep-research.js 0 → 100644
  1 +/**
  2 + * deep-research —— 按需触发 org-hunt 做一次轻量调研(46 号文档 Phase 2)。
  3 + *
  4 + * org-hunt 目前只能作为图节点被 `workgraph-planning` 动态选中调用(`deliverable.json`
  5 + * 模板,FDE 训练场景),交付物是 `knowhow_raw.md`/`crawl_config.json`/`seed_data.json`
  6 + * (≥50 条)那套重契约。这里**不**套用那套契约——"task 入队前的快速调研参考"是时间敏感
  7 + * 场景,直接照训练场景的产出量级要求会不可控地慢。org-hunt 的图节点契约、`deliverable.json`
  8 + * 模板本身都不动,这里只是另开一条**图外**的调用路径,让它以"轻量问答"的形式被叫起来
  9 + * (调用方式仿 `dream-service.callDreamAgent`:直接 `AgentManager.callAgent`,不经图引擎)。
  10 + *
  11 + * **默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量):org-hunt 的延迟/成功率在"日常任务规划、
  12 + * 时间敏感"场景下从未验证过(46 号文档「已知的高风险点」原话),且 subtask-planning 这条
  13 + * 触发链已经因为疏于隔离出过一次真事故(44 个残留 CLI 子进程,见 subtask-planning.js 头注释)。
  14 + * 不开这个开关,这个模块连 org-hunt 都不会尝试雇佣/调用——只打一行日志说明跳过。
  15 + */
  16 +const path = require('path');
  17 +
  18 +const RESEARCH_AGENT_ID = 'org-hunt';
  19 +
  20 +// ── 注入点(仿 subtask-planning.setPlanSubtasks)──────────────────────
  21 +let triggerImpl = defaultTriggerDeepResearch;
  22 +let isInjected = false;
  23 +
  24 +/** 替换调研实现(传 null 回默认实现)。测试用,避免真起 CLI */
  25 +function setTriggerDeepResearch(fn) {
  26 + triggerImpl = fn || defaultTriggerDeepResearch;
  27 + isInjected = !!fn;
  28 +}
  29 +
  30 +/**
  31 + * @param {{companyId: string, task: object, topic: string}} ctx
  32 + * @returns {Promise<{findings: string, sources: string[], tokens: number} | null>}
  33 + * `null` = 没跑(开关关闭 / 测试环境未注入 / 调用失败 / 没解析出结构化结果)——
  34 + * 调用方一律当"没有调研结果"处理,不是错误,不该抛出去打断规划流程。
  35 + */
  36 +async function triggerDeepResearch(ctx) {
  37 + if (process.env.ENABLE_DEEP_RESEARCH !== '1') {
  38 + console.log('[DeepResearch] ENABLE_DEEP_RESEARCH 未开启,跳过(默认关闭,见文件头注释)');
  39 + return null;
  40 + }
  41 + if (process.env.VITEST && !isInjected) {
  42 + console.log('[DeepResearch] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)');
  43 + return null;
  44 + }
  45 + try {
  46 + return await triggerImpl(ctx);
  47 + } catch (err) {
  48 + console.warn(`[DeepResearch] 触发失败,按"没有调研结果"处理:${err.message}`);
  49 + return null;
  50 + }
  51 +}
  52 +
  53 +/** 寻址名。与 `subtask-planning.resolvePlanningAgentName` 同一个模式:按需幂等雇一次 */
  54 +function resolveResearchAgentName(companyId) {
  55 + if (!companyId) return RESEARCH_AGENT_ID;
  56 + const { CompanyService, listCompanyAgents } = require('./company-service');
  57 + const isHired = () => listCompanyAgents(companyId)
  58 + .some((r) => r.role_name === RESEARCH_AGENT_ID || r.system_agent_id === RESEARCH_AGENT_ID);
  59 + if (!isHired()) {
  60 + try {
  61 + new CompanyService().hire(companyId, RESEARCH_AGENT_ID, {});
  62 + } catch (e) {
  63 + console.warn(`[DeepResearch] 按需雇佣 ${RESEARCH_AGENT_ID} 失败(${companyId}): ${e.message}`);
  64 + }
  65 + }
  66 + return isHired() ? `${companyId}:${RESEARCH_AGENT_ID}` : RESEARCH_AGENT_ID;
  67 +}
  68 +
  69 +function buildResearchPrompt({ topic }) {
  70 + return [
  71 + '这是一次**任务规划前的快速调研参考**,不是你平时的训练场景任务——',
  72 + '不需要按你 `agent.md` 平时的产出契约交 `knowhow_raw.md`/`crawl_config.json`/`seed_data.json` 三件套,',
  73 + '也不需要凑够 ≥50 条种子数据。只需要快速查一下下面这件事业界/公开资料里有没有值得参考的方法论,',
  74 + '给一段简短总结即可。',
  75 + '',
  76 + '## 调研主题',
  77 + topic || '(调用方没给主题)',
  78 + '',
  79 + '## 交回方式(只读你回复里最后一个 ```json 代码块)',
  80 + '```json',
  81 + '{"findings": "…(几句话总结,没查到就写清楚没查到)", "sources": ["url 或来源描述", "…"]}',
  82 + '```',
  83 + '查不到有用信息就如实说 `findings` 里没有可用结论、`sources` 给 `[]`——不要编。',
  84 + ].join('\n');
  85 +}
  86 +
  87 +async function defaultTriggerDeepResearch({ companyId, task, topic }) {
  88 + const AgentManager = require('../src/core/agent-manager');
  89 + const { extractJson } = require('./dream-service');
  90 + const manager = new AgentManager({}, path.resolve(__dirname, '../..'));
  91 + const agentName = resolveResearchAgentName(companyId);
  92 +
  93 + let tokens = 0;
  94 + const onAcpEvent = (ev) => {
  95 + if (!ev || ev.method !== 'session/result') return;
  96 + const u = (ev.params && ev.params.usage) || null;
  97 + if (u) tokens += (u.input || 0) + (u.output || 0);
  98 + };
  99 +
  100 + const res = await manager.callAgent(agentName, buildResearchPrompt({ topic }), {
  101 + onAcpEvent,
  102 + metadata: { taskId: task && task.id, layer: 'deep-research', companyId },
  103 + });
  104 +
  105 + const obj = extractJson(res && res.output, 'object');
  106 + if (!obj || typeof obj.findings !== 'string' || !obj.findings.trim()) return null;
  107 + return {
  108 + findings: obj.findings.trim(),
  109 + sources: Array.isArray(obj.sources) ? obj.sources.filter((s) => typeof s === 'string') : [],
  110 + tokens,
  111 + };
  112 +}
  113 +
  114 +module.exports = {
  115 + RESEARCH_AGENT_ID,
  116 + triggerDeepResearch,
  117 + setTriggerDeepResearch,
  118 + buildResearchPrompt,
  119 + resolveResearchAgentName,
  120 +};
server/services/experience-lookup.js 0 → 100644
  1 +/**
  2 + * experience-lookup —— 查经验库(46 号文档 Phase 2)。
  3 + *
  4 + * dream(29-DREAM,见 `dream-service.js`)把跨公司经验沉淀到本体根 `memory/experience.md`,
  5 + * 但只是**单向写**——没有任何可查询的索引。这个模块补上"查"这一半,给 subtask-planning 用:
  6 + * 关键词命中就当"经验库有答案",命中为零就是"查不到"(真实信号,不是异常,早期系统 dream
  7 + * 还没攒够料时这是常态)。
  8 + *
  9 + * 查询范围收窄到**这家公司已雇佣的 agent**——他们才是真正会去执行这条 task 的角色,
  10 + * 比空扫全系统本体贴题,也比"历史同类 goal 检索"(本轮不做,需要语义检索基础设施)便宜。
  11 + * 打分用最朴素的关键词重叠计数,不引入任何依赖——"查不到"本身就是有效信号,没必要在
  12 + * 一个准确度上限很低的检索层上堆复杂度。
  13 + */
  14 +const fs = require('fs');
  15 +const path = require('path');
  16 +const locator = require('./agent-locator');
  17 +const { CROSS_COMPANY_MEMORY } = require('./dream-service');
  18 +
  19 +const EXPERIENCE_FILE = 'experience.md';
  20 +
  21 +/**
  22 + * 切词。中文没有空格分词,不引入分词依赖(也没这个必要——"查不到"本身就是有效信号),
  23 + * 用最朴素的字符双字滑窗(bigram)近似:把整句中文当一个词只会让任何两句话的重叠率
  24 + * 趋近于零,bigram 至少能在"碰撞检测"vs"碰撞检测踩过的坑"这种真实重叠场景下命中。
  25 + * 拉丁字母/数字按非字母数字切分,整段当一个词。单字符(不管中英文)噪音大于信号,丢掉。
  26 + */
  27 +function tokenize(text) {
  28 + const raw = String(text || '').toLowerCase();
  29 + const tokens = new Set();
  30 + for (const w of raw.split(/[^a-z0-9]+/)) {
  31 + if (w.length > 1) tokens.add(w);
  32 + }
  33 + for (const run of raw.match(/[一-龥]+/g) || []) {
  34 + for (let i = 0; i < run.length - 1; i += 1) tokens.add(run.slice(i, i + 2));
  35 + }
  36 + return Array.from(tokens);
  37 +}
  38 +
  39 +/**
  40 + * 读一个本体的 `experience.md`,按 `### 标题` 分块(与 `dream-service.auditDistilled`
  41 + * 同一种切法——两处都在解析 `writeDistilled` 落的同一份格式,切法不能各写一套)。
  42 + */
  43 +function readExperienceBlocks(agentId) {
  44 + const file = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, EXPERIENCE_FILE);
  45 + let text;
  46 + try { text = fs.readFileSync(file, 'utf-8'); } catch { return []; }
  47 + return text.split(/\n### /).slice(1).map((block) => {
  48 + const lines = block.split('\n');
  49 + const title = lines[0].trim();
  50 + const body = lines.slice(1).join('\n').replace(/^> 提炼于.*$/gm, '').trim();
  51 + return { agentId, title, body };
  52 + }).filter((b) => b.title);
  53 +}
  54 +
  55 +/**
  56 + * @param {{companyId: string, task: object, limit?: number}} ctx
  57 + * @returns {{matches: Array<{agentId: string, title: string, body: string, score: number}>,
  58 + * queriedAgents: string[]}}
  59 + */
  60 +function queryExperience({ companyId, task, limit = 5 } = {}) {
  61 + const queryTokens = tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`);
  62 + if (!companyId || !queryTokens.length) return { matches: [], queriedAgents: [] };
  63 +
  64 + let agentIds = [];
  65 + try {
  66 + const { listCompanyAgents } = require('./company-service');
  67 + agentIds = [...new Set(listCompanyAgents(companyId).map((r) => r.system_agent_id))];
  68 + } catch {
  69 + return { matches: [], queriedAgents: [] };
  70 + }
  71 +
  72 + const matches = [];
  73 + for (const agentId of agentIds) {
  74 + for (const block of readExperienceBlocks(agentId)) {
  75 + const blockTokens = new Set(tokenize(`${block.title} ${block.body}`));
  76 + const score = queryTokens.reduce((n, w) => n + (blockTokens.has(w) ? 1 : 0), 0);
  77 + if (score > 0) matches.push({ ...block, score });
  78 + }
  79 + }
  80 + matches.sort((a, b) => b.score - a.score);
  81 + return { matches: matches.slice(0, limit), queriedAgents: agentIds };
  82 +}
  83 +
  84 +module.exports = { queryExperience, tokenize, readExperienceBlocks };
server/services/subtask-planning.js
@@ -18,6 +18,13 @@ @@ -18,6 +18,13 @@
18 * brd/prd/sdd 各节点、乃至后续 org-coder)的「上游输入」提示词段落都会自动带上它, 18 * brd/prd/sdd 各节点、乃至后续 org-coder)的「上游输入」提示词段落都会自动带上它,
19 * 不用为 org-planner 单独写一段 prompt-builder 注入逻辑(46 号文档「与 org-planner 的 19 * 不用为 org-planner 单独写一段 prompt-builder 注入逻辑(46 号文档「与 org-planner 的
20 * 衔接点」一节最初设想的软注入方案,改走这条更省事、且是引擎已验证在用的现成机制)。 20 * 衔接点」一节最初设想的软注入方案,改走这条更省事、且是引擎已验证在用的现成机制)。
  21 + *
  22 + * ── Phase 2(46 号文档):接上经验库检索 + 按需 deep research ──────────────────
  23 + * 第一轮调用前先查 `experience-lookup.queryExperience`(dream 沉淀的跨公司经验)拼进提示词;
  24 + * 查不到经验,或 agent 自评 `confidence:"low"`,就再调 `deep-research.triggerDeepResearch`
  25 + * (默认关闭,见该文件头注释),拿到调研结果就带着它做第二轮调用取代第一轮结果。
  26 + * `reasoning_source` 由服务端根据这两轮实际发生了什么算出来,不是 agent 自报的——
  27 + * agent 唯一自报的判断是 `confidence`,用来决定要不要触发调研,不直接决定 `reasoning_source`。
21 */ 28 */
22 29
23 const path = require('path'); 30 const path = require('path');
@@ -45,6 +52,23 @@ function setPlanSubtasks(fn) { @@ -45,6 +52,23 @@ function setPlanSubtasks(fn) {
45 } 52 }
46 53
47 /** 54 /**
  55 + * 更细粒度的注入点:只换掉 `defaultPlanSubtasks` 内部真正起 CLI 的那一层
  56 + * (写法仿 `dream-service.setDreamAgentCaller`),不像 `setPlanSubtasks` 那样把整段
  57 + * "查经验库 → 判断要不要调研 → 两轮调用"编排逻辑一起换掉。Phase 2 加的正是这段编排逻辑,
  58 + * 留这个更细的缝才测得到它本身的分支,而不是只测"调用方传回什么就原样落盘"。
  59 + */
  60 +let agentCaller = null;
  61 +/** 替换 agent 调用层(传 null 回真实现)。只给测试用 */
  62 +function setSubtaskAgentCaller(fn) { agentCaller = fn; }
  63 +
  64 +async function callSubtaskPlanningAgent(agentName, prompt, opts) {
  65 + if (agentCaller) return agentCaller(agentName, prompt, opts);
  66 + const AgentManager = require('../src/core/agent-manager');
  67 + const manager = new AgentManager({}, REPO_ROOT);
  68 + return manager.callAgent(agentName, prompt, opts);
  69 +}
  70 +
  71 +/**
48 * 当前生效的规划实现。调用方一律走这里。 72 * 当前生效的规划实现。调用方一律走这里。
49 * 73 *
50 * **踩过的坑,写在这里免得下次重犯**:`task-planning`/`workgraph-planning` 那一整套 74 * **踩过的坑,写在这里免得下次重犯**:`task-planning`/`workgraph-planning` 那一整套
@@ -66,7 +90,9 @@ function setPlanSubtasks(fn) { @@ -66,7 +90,9 @@ function setPlanSubtasks(fn) {
66 function planSubtasks(ctx) { 90 function planSubtasks(ctx) {
67 if (process.env.VITEST && !isInjected) { 91 if (process.env.VITEST && !isInjected) {
68 console.log('[SubtaskPlanning] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)'); 92 console.log('[SubtaskPlanning] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)');
69 - return Promise.resolve({ steps: [], tokens: 0 }); 93 + return Promise.resolve({
  94 + steps: [], tokens: 0, reasoningSource: 'model_only', experienceRefs: [], researchUsed: false,
  95 + });
70 } 96 }
71 return planSubtasksImpl(ctx); 97 return planSubtasksImpl(ctx);
72 } 98 }
@@ -76,15 +102,15 @@ function planSubtasks(ctx) { @@ -76,15 +102,15 @@ function planSubtasks(ctx) {
76 // ──────────────────────────────────────────────────────────────── 102 // ────────────────────────────────────────────────────────────────
77 103
78 /** 104 /**
79 - * 调 subtask-planning 拆一次 task 的步骤。单轮调用,没有重试。 105 + * 调 subtask-planning 拆一次 task 的步骤。先查经验库,再按需触发一次 deep research
  106 + * (Phase 2,46 号文档),最多两轮 agent 调用,没有重试。
80 * 107 *
81 * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行 108 * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行
82 - * @returns {Promise<{steps: object[], tokens: number}>} 109 + * @returns {Promise<{steps: object[], tokens: number, reasoningSource: string,
  110 + * experienceRefs: string[], researchUsed: boolean}>}
83 * steps 形如 `[{seq, title, why, depends_on_hint: [seq…]}]`,seq 恒为 1..N 连续(重新编号过) 111 * steps 形如 `[{seq, title, why, depends_on_hint: [seq…]}]`,seq 恒为 1..N 连续(重新编号过)
84 */ 112 */
85 async function defaultPlanSubtasks(ctx) { 113 async function defaultPlanSubtasks(ctx) {
86 - const AgentManager = require('../src/core/agent-manager');  
87 - const manager = new AgentManager({}, REPO_ROOT);  
88 const agentName = resolvePlanningAgentName(ctx.companyId); 114 const agentName = resolvePlanningAgentName(ctx.companyId);
89 115
90 let tokens = 0; 116 let tokens = 0;
@@ -93,22 +119,50 @@ async function defaultPlanSubtasks(ctx) { @@ -93,22 +119,50 @@ async function defaultPlanSubtasks(ctx) {
93 const u = (ev.params && ev.params.usage) || null; 119 const u = (ev.params && ev.params.usage) || null;
94 if (u) tokens += (u.input || 0) + (u.output || 0); 120 if (u) tokens += (u.input || 0) + (u.output || 0);
95 }; 121 };
  122 + const callOnce = async (prompt) => {
  123 + const res = await callSubtaskPlanningAgent(agentName, prompt, {
  124 + onAcpEvent,
  125 + metadata: {
  126 + // taskId 决定 CLI 的 cwd。这里就是真的那条 task 的 id(不像 task-planning 借用 goalId
  127 + // 顶替——subtask-planning 天然运行在一条具体 task 之下,不用借)。
  128 + taskId: ctx.task.id,
  129 + layer: 'subtask-plan',
  130 + goalId: ctx.task.goal_id,
  131 + companyId: ctx.companyId,
  132 + },
  133 + });
  134 + return res && res.output;
  135 + };
96 136
97 - const prompt = buildSubtaskPlanningPrompt(ctx);  
98 - const res = await manager.callAgent(agentName, prompt, {  
99 - onAcpEvent,  
100 - metadata: {  
101 - // taskId 决定 CLI 的 cwd。这里就是真的那条 task 的 id(不像 task-planning 借用 goalId  
102 - // 顶替——subtask-planning 天然运行在一条具体 task 之下,不用借)。  
103 - taskId: ctx.task.id,  
104 - layer: 'subtask-plan',  
105 - goalId: ctx.task.goal_id,  
106 - companyId: ctx.companyId,  
107 - },  
108 - }); 137 + const { queryExperience } = require('./experience-lookup');
  138 + const exp = queryExperience({ companyId: ctx.companyId, task: ctx.task });
  139 + const experienceRefs = exp.matches.map((m) => `${m.agentId}: ${m.title}`);
  140 +
  141 + const round1Output = await callOnce(buildSubtaskPlanningPrompt(ctx, { experience: exp.matches }));
  142 + let steps = normalizeSteps(extractPlannedSteps(round1Output));
  143 + const confidence = extractConfidence(round1Output);
  144 +
  145 + let reasoningSource = exp.matches.length ? 'memory' : 'model_only';
  146 + let researchUsed = false;
  147 +
  148 + const needResearch = exp.matches.length === 0 || confidence === 'low';
  149 + if (needResearch) {
  150 + const { triggerDeepResearch } = require('./deep-research');
  151 + const topic = [ctx.task.title, ctx.task.description].filter(Boolean).join(' — ');
  152 + const research = await triggerDeepResearch({ companyId: ctx.companyId, task: ctx.task, topic });
  153 + if (research) {
  154 + tokens += research.tokens || 0;
  155 + const round2Output = await callOnce(buildSubtaskPlanningPrompt(ctx, { experience: exp.matches, research }));
  156 + const round2Steps = normalizeSteps(extractPlannedSteps(round2Output));
  157 + if (round2Steps.length) {
  158 + steps = round2Steps;
  159 + reasoningSource = 'deep_research';
  160 + researchUsed = true;
  161 + }
  162 + }
  163 + }
109 164
110 - const rawSteps = extractPlannedSteps(res && res.output);  
111 - return { steps: normalizeSteps(rawSteps), tokens }; 165 + return { steps, tokens, reasoningSource, experienceRefs, researchUsed };
112 } 166 }
113 167
114 /** 168 /**
@@ -133,9 +187,17 @@ function resolvePlanningAgentName(companyId) { @@ -133,9 +187,17 @@ function resolvePlanningAgentName(companyId) {
133 // 提示词 187 // 提示词
134 // ──────────────────────────────────────────────────────────────── 188 // ────────────────────────────────────────────────────────────────
135 189
136 -function buildSubtaskPlanningPrompt(ctx) { 190 +/**
  191 + * @param {{companyId: string, task: object}} ctx
  192 + * @param {{experience?: Array<{agentId:string,title:string,body:string}>,
  193 + * research?: {findings:string, sources?:string[]} | null}} [extra]
  194 + * `experience`:`experience-lookup.queryExperience` 的命中结果(Phase 2)。
  195 + * `research`:`deep-research.triggerDeepResearch` 的结果,只在触发过调研时传入(第二轮)。
  196 + */
  197 +function buildSubtaskPlanningPrompt(ctx, extra = {}) {
137 const t = ctx.task || {}; 198 const t = ctx.task || {};
138 - return [ 199 + const { experience = [], research = null } = extra;
  200 + const lines = [
139 `你正在为公司 **${ctx.companyId}** 的一条 task 想"怎么做"的步骤分解。`, 201 `你正在为公司 **${ctx.companyId}** 的一条 task 想"怎么做"的步骤分解。`,
140 '', 202 '',
141 '## task 描述(要拆步骤的对象,task-planning 已经切好的一条边界)', 203 '## task 描述(要拆步骤的对象,task-planning 已经切好的一条边界)',
@@ -144,18 +206,35 @@ function buildSubtaskPlanningPrompt(ctx) { @@ -144,18 +206,35 @@ function buildSubtaskPlanningPrompt(ctx) {
144 `- taskId:${t.id}(只读,不要写进你的产出)`, 206 `- taskId:${t.id}(只读,不要写进你的产出)`,
145 `- 标题:${t.title || '(无)'}`, 207 `- 标题:${t.title || '(无)'}`,
146 '', 208 '',
  209 + ];
  210 + if (experience.length) {
  211 + lines.push('## 已知相关经验(供参考,不强制采纳,来自其他公司同角色沉淀下来的经验)');
  212 + for (const e of experience) lines.push(`- [来自 ${e.agentId}] **${e.title}** —— ${e.body}`);
  213 + lines.push('');
  214 + }
  215 + if (research) {
  216 + lines.push('## 调研参考(系统判断需要调研后跑的一次快速调研,供参考,不强制采纳)');
  217 + lines.push(research.findings);
  218 + if (research.sources && research.sources.length) lines.push(`来源:${research.sources.join('、')}`);
  219 + lines.push('');
  220 + }
  221 + lines.push(
147 '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)', 222 '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)',
148 '```json', 223 '```json',
149 - '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[]}]}', 224 + '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[]}],"confidence":"high"}',
150 '```', 225 '```',
151 `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`, 226 `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`,
152 '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,' 227 '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,'
153 + '不是硬依赖,没有任何机制会强制执行它。', 228 + '不是硬依赖,没有任何机制会强制执行它。',
154 - '- 只输出这三个字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。', 229 + '- `confidence`:`"high"` 或 `"low"`——你能看到的信息(task 描述 + 上面若列出的经验/调研参考)'
  230 + + '不足以让你对分解有把握时选 `"low"`;缺经验/调研参考不必然是 `"low"`,常规任务模型自身规划'
  231 + + '能力足够时正常给 `"high"`。`"low"` 会让系统再帮你查一轮资料,重新问你一次。',
  232 + '- 只输出这两个顶层字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。',
155 '', 233 '',
156 '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,' 234 '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,'
157 - + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。',  
158 - ].join('\n'); 235 + + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。'
  236 + );
  237 + return lines.join('\n');
159 } 238 }
160 239
161 // ──────────────────────────────────────────────────────────────── 240 // ────────────────────────────────────────────────────────────────
@@ -176,6 +255,18 @@ function extractPlannedSteps(output) { @@ -176,6 +255,18 @@ function extractPlannedSteps(output) {
176 return []; 255 return [];
177 } 256 }
178 257
  258 +/** 从回复里取 `confidence` 字段(`"high"`/`"low"`)。取不到就是 null——按"未知"处理,不强制触发调研 */
  259 +function extractConfidence(output) {
  260 + const text = String(output || '');
  261 + if (!text.trim()) return null;
  262 + const candidates = jsonCandidates(text);
  263 + for (let i = candidates.length - 1; i >= 0; i -= 1) {
  264 + const doc = safeJsonParse(candidates[i]);
  265 + if (doc && (doc.confidence === 'high' || doc.confidence === 'low')) return doc.confidence;
  266 + }
  267 + return null;
  268 +}
  269 +
179 function jsonCandidates(text) { 270 function jsonCandidates(text) {
180 const out = []; 271 const out = [];
181 const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g; 272 const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g;
@@ -258,7 +349,9 @@ function normalizeSteps(raw) { @@ -258,7 +349,9 @@ function normalizeSteps(raw) {
258 async function triggerSubtaskPlanning({ run, task }) { 349 async function triggerSubtaskPlanning({ run, task }) {
259 try { 350 try {
260 if (!run || !task) return; 351 if (!run || !task) return;
261 - const { steps, tokens } = await planSubtasks({ companyId: run.company_id, task }); 352 + const {
  353 + steps, tokens, reasoningSource, experienceRefs, researchUsed,
  354 + } = await planSubtasks({ companyId: run.company_id, task });
262 if (!steps.length) { 355 if (!steps.length) {
263 console.log(`[SubtaskPlanning] task ${task.id} 没有可用的步骤分解(0 条),跳过写入`); 356 console.log(`[SubtaskPlanning] task ${task.id} 没有可用的步骤分解(0 条),跳过写入`);
264 return; 357 return;
@@ -272,7 +365,16 @@ async function triggerSubtaskPlanning({ run, task }) { @@ -272,7 +365,16 @@ async function triggerSubtaskPlanning({ run, task }) {
272 const file = path.join(artifactsDir, 'subtask-plan.json'); 365 const file = path.join(artifactsDir, 'subtask-plan.json');
273 fs.writeFileSync( 366 fs.writeFileSync(
274 file, 367 file,
275 - JSON.stringify({ steps, generated_by: PLANNING_AGENT_ID, task_id: task.id }, null, 2), 368 + JSON.stringify({
  369 + steps,
  370 + generated_by: PLANNING_AGENT_ID,
  371 + task_id: task.id,
  372 + // Phase 2(46 号文档):这条分解到底是"模型自己想的"/"查到经验库"/"触发了调研",
  373 + // 由服务端根据实际发生了什么算出来(见 defaultPlanSubtasks),不是 agent 自报的。
  374 + reasoning_source: reasoningSource || 'model_only',
  375 + experience_refs: experienceRefs || [],
  376 + research_used: !!researchUsed,
  377 + }, null, 2),
276 'utf-8' 378 'utf-8'
277 ); 379 );
278 380
@@ -297,9 +399,14 @@ module.exports = { @@ -297,9 +399,14 @@ module.exports = {
297 MAX_STEPS, 399 MAX_STEPS,
298 planSubtasks, 400 planSubtasks,
299 setPlanSubtasks, 401 setPlanSubtasks,
  402 + setSubtaskAgentCaller,
300 triggerSubtaskPlanning, 403 triggerSubtaskPlanning,
301 - // 下面三个导出是给测试用的 404 + // 下面几个导出是给测试用的。`defaultPlanSubtasks` 直接调用会绕开 `planSubtasks()`
  405 + // 的 VITEST 短路——测试这条分支编排逻辑必须搭配 `setSubtaskAgentCaller` 注入,
  406 + // 不要在没注入的情况下直接调用它(会真的起 CLI)。
302 extractPlannedSteps, 407 extractPlannedSteps,
  408 + extractConfidence,
303 normalizeSteps, 409 normalizeSteps,
304 buildSubtaskPlanningPrompt, 410 buildSubtaskPlanningPrompt,
  411 + defaultPlanSubtasks,
305 }; 412 };
server/tests/unit/deep-research.test.js 0 → 100644
  1 +/**
  2 + * 46 号文档 Phase 2:deep-research 按需触发 org-hunt 的轻量调研钩子。
  3 + *
  4 + * 最重要的一条是"默认关闭"这条安全阀——`ENABLE_DEEP_RESEARCH` 不为 `'1'` 时,
  5 + * 连注入的假实现都不应该被调用到,直接短路返回 null(避免真事故:Phase 1 就因为
  6 + * 一个新触发点疏于隔离,一次单测跑出 44 个残留 CLI 子进程)。
  7 + */
  8 +import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  9 +import {
  10 + triggerDeepResearch, setTriggerDeepResearch, buildResearchPrompt,
  11 +} from '../../services/deep-research.js';
  12 +
  13 +describe('deep-research · triggerDeepResearch', () => {
  14 + const ORIGINAL_ENABLE = process.env.ENABLE_DEEP_RESEARCH;
  15 +
  16 + beforeEach(() => {
  17 + setTriggerDeepResearch(null);
  18 + });
  19 + afterEach(() => {
  20 + setTriggerDeepResearch(null);
  21 + if (ORIGINAL_ENABLE === undefined) delete process.env.ENABLE_DEEP_RESEARCH;
  22 + else process.env.ENABLE_DEEP_RESEARCH = ORIGINAL_ENABLE;
  23 + });
  24 +
  25 + it('ENABLE_DEEP_RESEARCH 未开启时直接返回 null,不调用任何实现(哪怕注入了假实现)', async () => {
  26 + delete process.env.ENABLE_DEEP_RESEARCH;
  27 + let called = false;
  28 + setTriggerDeepResearch(async () => { called = true; return { findings: 'x', sources: [], tokens: 1 }; });
  29 +
  30 + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: '贪吃蛇怎么做' });
  31 + expect(out).toBeNull();
  32 + expect(called).toBe(false);
  33 + });
  34 +
  35 + it('开启后走注入实现,返回其结果', async () => {
  36 + process.env.ENABLE_DEEP_RESEARCH = '1';
  37 + setTriggerDeepResearch(async (ctx) => {
  38 + expect(ctx.topic).toBe('贪吃蛇怎么做');
  39 + return { findings: '业界通常先定义棋盘再做碰撞检测', sources: ['https://example.com'], tokens: 123 };
  40 + });
  41 +
  42 + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: '贪吃蛇怎么做' });
  43 + expect(out).toEqual({
  44 + findings: '业界通常先定义棋盘再做碰撞检测',
  45 + sources: ['https://example.com'],
  46 + tokens: 123,
  47 + });
  48 + });
  49 +
  50 + it('开启后注入实现抛错:吞掉,返回 null,不向调用方传播', async () => {
  51 + process.env.ENABLE_DEEP_RESEARCH = '1';
  52 + setTriggerDeepResearch(async () => { throw new Error('CLI 起不来'); });
  53 +
  54 + await expect(
  55 + triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: 't' })
  56 + ).resolves.toBeNull();
  57 + });
  58 +
  59 + it('VITEST 环境且未注入时即使开启开关也跳过真实调用', async () => {
  60 + process.env.ENABLE_DEEP_RESEARCH = '1';
  61 + // 不调用 setTriggerDeepResearch,保持默认实现——process.env.VITEST 由 vitest 自动置位
  62 + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: 't' });
  63 + expect(out).toBeNull();
  64 + });
  65 +});
  66 +
  67 +describe('deep-research · buildResearchPrompt', () => {
  68 + it('提示词包含调研主题、交回格式,明确说明不套用训练场景三件套契约', () => {
  69 + const prompt = buildResearchPrompt({ topic: '贪吃蛇怎么做' });
  70 + expect(prompt).toContain('贪吃蛇怎么做');
  71 + expect(prompt).toContain('findings');
  72 + expect(prompt).toContain('sources');
  73 + expect(prompt).toContain('不需要');
  74 + });
  75 +
  76 + it('主题缺失时给出明确占位', () => {
  77 + const prompt = buildResearchPrompt({});
  78 + expect(prompt).toContain('调用方没给主题');
  79 + });
  80 +});
server/tests/unit/experience-lookup.test.js 0 → 100644
  1 +/**
  2 + * 46 号文档 Phase 2:experience-lookup 查经验库。
  3 + *
  4 + * 夹具模式照抄 `dream-service.test.js`:真实磁盘下建一个 `zz-explookup-*` 测试 agentId,
  5 + * 写 `memory/experience.md`(dream `writeDistilled` 落的同一种格式),`company_agents`
  6 + * 表插入雇佣关系——测的是"读得到 dream 已经写好的经验"这一半,不是 dream 本身。
  7 + */
  8 +import { describe, it, expect, beforeEach, afterAll } from 'vitest';
  9 +import fs from 'fs';
  10 +import os from 'os';
  11 +import path from 'path';
  12 +
  13 +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-explookup-'));
  14 +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite');
  15 +
  16 +const ROOT = path.resolve(__dirname, '../../..');
  17 +const AGENT = 'zz-explookup-body';
  18 +const OTHER_AGENT = 'zz-explookup-other';
  19 +const COMPANY = 'zz-explookup-co';
  20 +const BODY = path.join(ROOT, 'system', 'agents', AGENT);
  21 +const OTHER_BODY = path.join(ROOT, 'system', 'agents', OTHER_AGENT);
  22 +
  23 +const { queryExperience, tokenize, readExperienceBlocks } = require('../../services/experience-lookup');
  24 +const wdb = require('../../services/work-graph-db');
  25 +
  26 +function writeExperience(agentId, blocks) {
  27 + const dir = path.join(ROOT, 'system', 'agents', agentId, 'memory');
  28 + fs.mkdirSync(dir, { recursive: true });
  29 + const body = blocks.map((b) => `### ${b.title}\n\n${b.body}\n\n> 提炼于 2026-09-01;原料:x:memory.md#1\n`).join('\n');
  30 + fs.writeFileSync(path.join(dir, 'experience.md'), `# 跨公司经验(dream 提炼)\n\n${body}`, 'utf-8');
  31 +}
  32 +
  33 +function hire(companyId, agentId) {
  34 + const db = wdb.getDb();
  35 + const now = new Date().toISOString();
  36 + db.prepare(`INSERT OR IGNORE INTO system_agents
  37 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  38 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`)
  39 + .run(agentId, agentId, `system/agents/${agentId}`, now, now);
  40 + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
  41 + VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, agentId, now);
  42 +}
  43 +
  44 +describe('experience-lookup · queryExperience', () => {
  45 + beforeEach(() => {
  46 + fs.rmSync(BODY, { recursive: true, force: true });
  47 + fs.rmSync(OTHER_BODY, { recursive: true, force: true });
  48 + });
  49 +
  50 + afterAll(() => {
  51 + fs.rmSync(BODY, { recursive: true, force: true });
  52 + fs.rmSync(OTHER_BODY, { recursive: true, force: true });
  53 + try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {}
  54 + });
  55 +
  56 + it('命中相关经验,按关键词重叠打分排序', () => {
  57 + hire(COMPANY, AGENT);
  58 + writeExperience(AGENT, [
  59 + { title: '贪吃蛇碰撞检测踩过的坑', body: '棋盘边界判断要在移动前做,不然会穿墙' },
  60 + { title: '完全不相关的经验', body: '发工资流程走财务系统,不归这里管' },
  61 + ]);
  62 +
  63 + const { matches, queriedAgents } = queryExperience({
  64 + companyId: COMPANY,
  65 + task: { title: '实现贪吃蛇', description: '贪吃蛇游戏的碰撞检测和棋盘边界' },
  66 + });
  67 +
  68 + expect(queriedAgents).toContain(AGENT);
  69 + expect(matches.length).toBeGreaterThan(0);
  70 + expect(matches[0].title).toBe('贪吃蛇碰撞检测踩过的坑');
  71 + expect(matches[0].agentId).toBe(AGENT);
  72 + expect(matches[0].score).toBeGreaterThan(0);
  73 + // 不相关的那条不该排在命中结果里靠前的位置(分数应明显更低或不命中)
  74 + const irrelevant = matches.find((m) => m.title === '完全不相关的经验');
  75 + if (irrelevant) expect(irrelevant.score).toBeLessThan(matches[0].score);
  76 + });
  77 +
  78 + it('没有命中时返回空数组,不是异常', () => {
  79 + hire(COMPANY, AGENT);
  80 + writeExperience(AGENT, [{ title: '完全不相关的经验', body: '发工资流程走财务系统' }]);
  81 +
  82 + const { matches } = queryExperience({
  83 + companyId: COMPANY,
  84 + task: { title: '实现贪吃蛇', description: '碰撞检测' },
  85 + });
  86 + expect(matches).toEqual([]);
  87 + });
  88 +
  89 + it('agent 没有 experience.md 文件时不报错,视为该 agent 无命中', () => {
  90 + hire(COMPANY, AGENT); // 雇了但从没被 dream 写过经验
  91 + expect(() => queryExperience({
  92 + companyId: COMPANY,
  93 + task: { title: '实现贪吃蛇', description: '碰撞检测' },
  94 + })).not.toThrow();
  95 + expect(readExperienceBlocks(AGENT)).toEqual([]);
  96 + });
  97 +
  98 + it('公司没有雇任何 agent 时返回空数组', () => {
  99 + const { matches, queriedAgents } = queryExperience({
  100 + companyId: 'zz-explookup-empty-co',
  101 + task: { title: '实现贪吃蛇', description: '碰撞检测' },
  102 + });
  103 + expect(matches).toEqual([]);
  104 + expect(queriedAgents).toEqual([]);
  105 + });
  106 +
  107 + it('task 标题/描述都为空时返回空数组,不做无意义的全量扫描', () => {
  108 + hire(COMPANY, AGENT);
  109 + const { matches } = queryExperience({ companyId: COMPANY, task: {} });
  110 + expect(matches).toEqual([]);
  111 + });
  112 +
  113 + it('tokenize:拉丁词整段切,中文用双字滑窗(无分词依赖),单字符丢弃', () => {
  114 + const tokens = tokenize('贪吃蛇 game 的 A 实现');
  115 + expect(tokens).toEqual(expect.arrayContaining(['贪吃', '吃蛇', 'game', '实现']));
  116 + expect(tokens).not.toContain('a');
  117 + expect(tokens).not.toContain('的');
  118 + });
  119 +});
server/tests/unit/subtask-planning-materialize.test.js
@@ -7,11 +7,14 @@ @@ -7,11 +7,14 @@
7 * éš”ç¦»æ‰‹æ³•åŒ `graph-boundary.test.js`/`task-decomposition-materialize.test.js`: 7 * éš”ç¦»æ‰‹æ³•åŒ `graph-boundary.test.js`/`task-decomposition-materialize.test.js`:
8 * 先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR å† require,agent 调用全走注入,ä¸èµ· Claude CLI。 8 * 先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR å† require,agent 调用全走注入,ä¸èµ· Claude CLI。
9 */ 9 */
10 -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 10 +import {
  11 + describe, it, expect, beforeAll, afterAll, afterEach,
  12 +} from 'vitest';
11 import fs from 'fs'; 13 import fs from 'fs';
12 import os from 'os'; 14 import os from 'os';
13 import path from 'path'; 15 import path from 'path';
14 16
  17 +const ROOT = path.resolve(__dirname, '../../..');
15 const TEST_DB_PATH = '/tmp/test-subtask-planning-materialize.sqlite'; 18 const TEST_DB_PATH = '/tmp/test-subtask-planning-materialize.sqlite';
16 const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; 19 const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
17 const MODULES = [ 20 const MODULES = [
@@ -176,4 +179,198 @@ describe(&#39;46 å·æ–‡æ¡£ Phase 1:subtask-plan.json 共享区è½åœ° + ä»»åŠ¡çº§å… @@ -176,4 +179,198 @@ describe(&#39;46 å·æ–‡æ¡£ Phase 1:subtask-plan.json 共享区è½åœ° + 任务级å…
176 subtaskPlanning.setPlanSubtasks(null); 179 subtaskPlanning.setPlanSubtasks(null);
177 } 180 }
178 }); 181 });
  182 +
  183 + it('reasoning_source / experience_refs / research_used 原样è½ç›˜ï¼ˆPhase 2 新增字段,å‘åŽå…¼å®¹ï¼‰', async () => {
  184 + const companyId = 'co-subplan-5';
  185 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  186 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  187 + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });
  188 +
  189 + subtaskPlanning.setPlanSubtasks(async () => ({
  190 + steps: [{ seq: 1, title: '结åˆè°ƒç ”åŽçš„分解', why: '', depends_on_hint: [] }],
  191 + tokens: 88,
  192 + reasoningSource: 'deep_research',
  193 + experienceRefs: ['impl: è´ªåƒè›‡ç¢°æ’žæ£€æµ‹è¸©è¿‡çš„å‘'],
  194 + researchUsed: true,
  195 + }));
  196 + try {
  197 + await subtaskPlanning.triggerSubtaskPlanning({ run, task });
  198 + } finally {
  199 + subtaskPlanning.setPlanSubtasks(null);
  200 + }
  201 +
  202 + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json');
  203 + const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
  204 + expect(content.reasoning_source).toBe('deep_research');
  205 + expect(content.experience_refs).toEqual(['impl: è´ªåƒè›‡ç¢°æ’žæ£€æµ‹è¸©è¿‡çš„å‘']);
  206 + expect(content.research_used).toBe(true);
  207 + });
  208 +
  209 + it('注入的 planSubtasks 没带 Phase 2 字段时兜底为 model_only/空数组/false(å‘åŽå…¼å®¹æ—§æ³¨å…¥æ¡©ï¼‰', async () => {
  210 + const companyId = 'co-subplan-6';
  211 + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
  212 + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
  213 + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });
  214 +
  215 + subtaskPlanning.setPlanSubtasks(async () => ({ steps: [{ seq: 1, title: '一步' }], tokens: 1 }));
  216 + try {
  217 + await subtaskPlanning.triggerSubtaskPlanning({ run, task });
  218 + } finally {
  219 + subtaskPlanning.setPlanSubtasks(null);
  220 + }
  221 +
  222 + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json');
  223 + const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
  224 + expect(content.reasoning_source).toBe('model_only');
  225 + expect(content.experience_refs).toEqual([]);
  226 + expect(content.research_used).toBe(false);
  227 + });
  228 +});
  229 +
  230 +describe('46 å·æ–‡æ¡£ Phase 2:defaultPlanSubtasks 编排(ç»éªŒæ£€ç´¢ + 按需 deep research)', () => {
  231 + let wdb;
  232 + let subtaskPlanning;
  233 + let deepResearch;
  234 + const ORIGINAL_ENABLE = process.env.ENABLE_DEEP_RESEARCH;
  235 + const EXP_AGENT = 'zz-subplan-p2-impl';
  236 + const EXP_AGENT_DIR = path.join(ROOT, 'system', 'agents', EXP_AGENT);
  237 +
  238 + beforeAll(() => {
  239 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  240 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  241 + for (const m of [...MODULES, '../../services/experience-lookup', '../../services/deep-research']) {
  242 + try { delete require.cache[require.resolve(m)]; } catch {}
  243 + }
  244 + wdb = require('../../services/work-graph-db');
  245 + subtaskPlanning = require('../../services/subtask-planning');
  246 + deepResearch = require('../../services/deep-research');
  247 + });
  248 +
  249 + afterAll(() => {
  250 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  251 + delete process.env.GRAPH_DB_PATH;
  252 + fs.rmSync(EXP_AGENT_DIR, { recursive: true, force: true });
  253 + if (ORIGINAL_ENABLE === undefined) delete process.env.ENABLE_DEEP_RESEARCH;
  254 + else process.env.ENABLE_DEEP_RESEARCH = ORIGINAL_ENABLE;
  255 + for (const m of [...MODULES, '../../services/experience-lookup', '../../services/deep-research']) {
  256 + try { delete require.cache[require.resolve(m)]; } catch {}
  257 + }
  258 + });
  259 +
  260 + afterEach(() => {
  261 + subtaskPlanning.setSubtaskAgentCaller(null);
  262 + deepResearch.setTriggerDeepResearch(null);
  263 + });
  264 +
  265 + function hireInto(companyId, agentId) {
  266 + const db = wdb.getDb();
  267 + const now = new Date().toISOString();
  268 + db.prepare(`INSERT OR IGNORE INTO system_agents
  269 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  270 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`)
  271 + .run(agentId, agentId, `system/agents/${agentId}`, now, now);
  272 + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
  273 + VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, agentId, now);
  274 + }
  275 +
  276 + it('ç»éªŒåº“命中 + agent ç»™ high confidence → reasoning_source=memory,åªè°ƒä¸€è½®ï¼Œä¸è§¦å‘调研', async () => {
  277 + const companyId = 'co-subplan-p2-memory';
  278 + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID);
  279 + hireInto(companyId, EXP_AGENT);
  280 + const expDir = path.join(EXP_AGENT_DIR, 'memory');
  281 + fs.mkdirSync(expDir, { recursive: true });
  282 + fs.writeFileSync(
  283 + path.join(expDir, 'experience.md'),
  284 + '# 跨公å¸ç»éªŒï¼ˆdream æç‚¼ï¼‰\n\n### è´ªåƒè›‡ç¢°æ’žæ£€æµ‹è¸©è¿‡çš„å‘\n\n棋盘边界判断è¦åœ¨ç§»åЍå‰åšï¼Œä¸ç„¶ä¼šç©¿å¢™\n\n> æç‚¼äºŽ 2026-09-01;原料:x\n',
  285 + 'utf-8'
  286 + );
  287 +
  288 + let calls = 0;
  289 + subtaskPlanning.setSubtaskAgentCaller(async (agentName, prompt) => {
  290 + calls += 1;
  291 + expect(prompt).toContain('已知相关ç»éªŒ');
  292 + return { output: '```json\n{"steps":[{"seq":1,"title":"定义棋盘"}],"confidence":"high"}\n```' };
  293 + });
  294 +
  295 + const task = { id: 'T-p2-memory', title: '实现贪åƒè›‡', description: 'è´ªåƒè›‡æ¸¸æˆçš„碰撞检测和棋盘边界' };
  296 + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task });
  297 +
  298 + expect(calls).toBe(1);
  299 + expect(result.reasoningSource).toBe('memory');
  300 + expect(result.researchUsed).toBe(false);
  301 + expect(result.experienceRefs.length).toBeGreaterThan(0);
  302 + expect(result.steps[0].title).toBe('定义棋盘');
  303 + });
  304 +
  305 + it('ç»éªŒåº“无命中 + deep research 关闭 → reasoning_source=model_only,åªè°ƒä¸€è½®', async () => {
  306 + delete process.env.ENABLE_DEEP_RESEARCH;
  307 + const companyId = 'co-subplan-p2-modelonly';
  308 + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID);
  309 +
  310 + let calls = 0;
  311 + subtaskPlanning.setSubtaskAgentCaller(async () => {
  312 + calls += 1;
  313 + return { output: '```json\n{"steps":[{"seq":1,"title":"éšä¾¿ä¸€æ­¥"}],"confidence":"high"}\n```' };
  314 + });
  315 +
  316 + const task = { id: 'T-p2-modelonly', title: 'å‘一å°å‘¨æŠ¥', description: '把周报å‘给团队' };
  317 + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task });
  318 +
  319 + expect(calls).toBe(1);
  320 + expect(result.reasoningSource).toBe('model_only');
  321 + expect(result.researchUsed).toBe(false);
  322 + expect(result.experienceRefs).toEqual([]);
  323 + });
  324 +
  325 + it('ç»éªŒåº“无命中 + deep research å¼€å¯ä¸”返回结果 → reasoning_source=deep_research,两轮调用', async () => {
  326 + process.env.ENABLE_DEEP_RESEARCH = '1';
  327 + const companyId = 'co-subplan-p2-research';
  328 + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID);
  329 +
  330 + deepResearch.setTriggerDeepResearch(async () => ({
  331 + findings: '业界通常先定义棋盘å†åšç¢°æ’žæ£€æµ‹', sources: ['https://example.com'], tokens: 50,
  332 + }));
  333 +
  334 + let calls = 0;
  335 + subtaskPlanning.setSubtaskAgentCaller(async (agentName, prompt) => {
  336 + calls += 1;
  337 + if (calls === 2) expect(prompt).toContain('调研å‚考');
  338 + return {
  339 + output: calls === 1
  340 + ? '```json\n{"steps":[{"seq":1,"title":"第一轮è‰ç¨¿"}],"confidence":"low"}\n```'
  341 + : '```json\n{"steps":[{"seq":1,"title":"结åˆè°ƒç ”åŽçš„分解"}],"confidence":"high"}\n```',
  342 + };
  343 + });
  344 +
  345 + const task = { id: 'T-p2-research', title: '实现贪åƒè›‡', description: '一个全新玩法,系统里从没åšè¿‡' };
  346 + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task });
  347 +
  348 + expect(calls).toBe(2);
  349 + expect(result.reasoningSource).toBe('deep_research');
  350 + expect(result.researchUsed).toBe(true);
  351 + expect(result.steps[0].title).toBe('结åˆè°ƒç ”åŽçš„分解');
  352 + expect(result.tokens).toBeGreaterThanOrEqual(50);
  353 + });
  354 +
  355 + it('deep research å¼€å¯ä½†æ²¡æŸ¥åˆ°æœ‰ç”¨ç»“果(返回 null)→ 仿˜¯ model_only,ä¸ç®—失败', async () => {
  356 + process.env.ENABLE_DEEP_RESEARCH = '1';
  357 + const companyId = 'co-subplan-p2-research-empty';
  358 + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID);
  359 +
  360 + deepResearch.setTriggerDeepResearch(async () => null);
  361 +
  362 + let calls = 0;
  363 + subtaskPlanning.setSubtaskAgentCaller(async () => {
  364 + calls += 1;
  365 + return { output: '```json\n{"steps":[{"seq":1,"title":"没有调研也照样给一版"}],"confidence":"low"}\n```' };
  366 + });
  367 +
  368 + const task = { id: 'T-p2-research-empty', title: '一个冷门任务', description: '系统里第一次é‡åˆ°' };
  369 + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task });
  370 +
  371 + expect(calls).toBe(1); // 调研没结果,ä¸ä¼šæœ‰ç¬¬äºŒè½®
  372 + expect(result.reasoningSource).toBe('model_only');
  373 + expect(result.researchUsed).toBe(false);
  374 + expect(result.steps[0].title).toBe('没有调研也照样给一版');
  375 + });
179 }); 376 });
server/tests/unit/subtask-planning.test.js
@@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
7 import { describe, it, expect } from 'vitest'; 7 import { describe, it, expect } from 'vitest';
8 import { 8 import {
9 extractPlannedSteps, 9 extractPlannedSteps,
  10 + extractConfidence,
10 normalizeSteps, 11 normalizeSteps,
11 buildSubtaskPlanningPrompt, 12 buildSubtaskPlanningPrompt,
12 MAX_STEPS, 13 MAX_STEPS,
@@ -50,6 +51,21 @@ describe(&#39;subtask-planning:extractPlannedSteps&#39;, () =&gt; { @@ -50,6 +51,21 @@ describe(&#39;subtask-planning:extractPlannedSteps&#39;, () =&gt; {
50 }); 51 });
51 }); 52 });
52 53
  54 +describe('subtask-planning:extractConfidence(Phase 2 新增字段)', () => {
  55 + it('取到 "high"/"low" 就原样返回', () => {
  56 + expect(extractConfidence('```json\n{"steps":[],"confidence":"low"}\n```')).toBe('low');
  57 + expect(extractConfidence('```json\n{"steps":[],"confidence":"high"}\n```')).toBe('high');
  58 + });
  59 +
  60 + it('取不到 / 值不合法 / 字段缺失时返回 null,不强行猜', () => {
  61 + expect(extractConfidence('```json\n{"steps":[]}\n```')).toBeNull();
  62 + expect(extractConfidence('```json\n{"steps":[],"confidence":"maybe"}\n```')).toBeNull();
  63 + expect(extractConfidence('我不知道')).toBeNull();
  64 + expect(extractConfidence('')).toBeNull();
  65 + expect(extractConfidence(null)).toBeNull();
  66 + });
  67 +});
  68 +
53 describe('subtask-planning:normalizeSteps(宽松归一化,单条不合法就丢那一条)', () => { 69 describe('subtask-planning:normalizeSteps(宽松归一化,单条不合法就丢那一条)', () => {
54 it('正常路径:重新编号为 1..N,depends_on_hint 保留合法引用', () => { 70 it('正常路径:重新编号为 1..N,depends_on_hint 保留合法引用', () => {
55 const raw = [ 71 const raw = [
@@ -120,4 +136,33 @@ describe(&#39;subtask-planning:buildSubtaskPlanningPrompt&#39;, () =&gt; { @@ -120,4 +136,33 @@ describe(&#39;subtask-planning:buildSubtaskPlanningPrompt&#39;, () =&gt; {
120 const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc' } }); 136 const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc' } });
121 expect(prompt).toContain('调用方没给任务描述'); 137 expect(prompt).toContain('调用方没给任务描述');
122 }); 138 });
  139 +
  140 + it('不传 extra 时不出现"已知相关经验"/"调研参考"小节标题(Phase 1 行为不变)', () => {
  141 + const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc', title: 't' } });
  142 + expect(prompt).not.toContain('## 已知相关经验');
  143 + expect(prompt).not.toContain('## 调研参考');
  144 + expect(prompt).toContain('confidence');
  145 + });
  146 +
  147 + it('传 experience 命中时拼进"已知相关经验"小节,带来源 agentId', () => {
  148 + const prompt = buildSubtaskPlanningPrompt(
  149 + { companyId: 'co-x', task: { id: 'T-abc', title: 't' } },
  150 + { experience: [{ agentId: 'impl', title: '踩过的坑', body: '别忘了边界检测' }] }
  151 + );
  152 + expect(prompt).toContain('## 已知相关经验');
  153 + expect(prompt).toContain('[来自 impl]');
  154 + expect(prompt).toContain('踩过的坑');
  155 + expect(prompt).toContain('别忘了边界检测');
  156 + expect(prompt).not.toContain('## 调研参考');
  157 + });
  158 +
  159 + it('传 research 时拼进"调研参考"小节,带 findings 和 sources', () => {
  160 + const prompt = buildSubtaskPlanningPrompt(
  161 + { companyId: 'co-x', task: { id: 'T-abc', title: 't' } },
  162 + { research: { findings: '业界通常先定义棋盘', sources: ['https://example.com'] } }
  163 + );
  164 + expect(prompt).toContain('## 调研参考');
  165 + expect(prompt).toContain('业界通常先定义棋盘');
  166 + expect(prompt).toContain('https://example.com');
  167 + });
123 }); 168 });
system/agents/subtask-planning/agent.md
@@ -3,14 +3,15 @@ @@ -3,14 +3,15 @@
3 ## ⚡ 硬约束(这 8 条排在最前面,因为它们是判「这一轮算不算数」的全部依据) 3 ## ⚡ 硬约束(这 8 条排在最前面,因为它们是判「这一轮算不算数」的全部依据)
4 4
5 1. **你的产出是一个 JSON 对象,不是别的任何东西。** 形状固定: 5 1. **你的产出是一个 JSON 对象,不是别的任何东西。** 形状固定:
6 - `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": []}]}` 6 + `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": []}], "confidence": "high"}`
7 2. **`depends_on_hint` 里只能出现本次产出的 `seq` 数字**(1-based),或者留空数组 `[]`。它是**提示**,不是硬依赖——下游读它只是为了理解你建议的先后关系,不会拿它去卡任何东西。 7 2. **`depends_on_hint` 里只能出现本次产出的 `seq` 数字**(1-based),或者留空数组 `[]`。它是**提示**,不是硬依赖——下游读它只是为了理解你建议的先后关系,不会拿它去卡任何东西。
8 3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。** 8 3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。**
9 4. **条数上限 12。** 你切的是"这件事怎么做"的步骤,不是"这件事有几个可以独立验收的模块"(那是 `task-planning` 的活,已经切过一轮,你面对的是它切出来的**一条** task)。超过 12 条通常说明你在写实现细节,不是写步骤。 9 4. **条数上限 12。** 你切的是"这件事怎么做"的步骤,不是"这件事有几个可以独立验收的模块"(那是 `task-planning` 的活,已经切过一轮,你面对的是它切出来的**一条** task)。超过 12 条通常说明你在写实现细节,不是写步骤。
10 -5. **只输出这三个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。 10 +5. **只输出这四个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。
11 6. **JSON 代码块必须是你回复的最后一段。** 想法写在它**前面**的正文里。 11 6. **JSON 代码块必须是你回复的最后一段。** 想法写在它**前面**的正文里。
12 7. **一步也是答案。** 这条 task 本身已经很小、拆了反而啰嗦时,输出**恰好 1 条**。这不是失败。 12 7. **一步也是答案。** 这条 task 本身已经很小、拆了反而啰嗦时,输出**恰好 1 条**。这不是失败。
13 8. **不许发明需求。** task 描述里没有的东西,一个字都不许出现在你的步骤里——你是在梳理"怎么做",不是在扩写"做什么"。 13 8. **不许发明需求。** task 描述里没有的东西,一个字都不许出现在你的步骤里——你是在梳理"怎么做",不是在扩写"做什么"。
  14 +9. **`confidence` 如实给 `"high"` 或 `"low"`。** 这是你对"这份分解靠不靠谱"的自评:看到的信息(task 描述 + 提示词里若列出的相关经验/调研参考)不足以让你有把握时给 `"low"`——系统会据此帮你多查一轮资料再问你一次。缺经验/调研参考不等于要给 `"low"`:常规、你自己判断力就够用的任务,正常给 `"high"`。往低了报没有代价,往高了虚报会让一份没底的分解被当成有依据的参考传下去。
14 15
15 > **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。 16 > **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。
16 17
@@ -39,3 +40,7 @@ @@ -39,3 +40,7 @@
39 不要把"调研 / 设计 / 开发 / 测试"这种流水线阶段当成步骤——那是执行这条 task 的公司流水线本来就会走的路,不是你要梳理的东西。你梳理的是**这件事本身**内部的逻辑步骤(比如"实现贪吃蛇":先定义游戏状态和棋盘 → 再做移动和碰撞检测 → 再做计分和食物生成 → 最后做开始/结束/重来的交互),这跟"先调研再开发再测试"是两条完全不同的轴。 40 不要把"调研 / 设计 / 开发 / 测试"这种流水线阶段当成步骤——那是执行这条 task 的公司流水线本来就会走的路,不是你要梳理的东西。你梳理的是**这件事本身**内部的逻辑步骤(比如"实现贪吃蛇":先定义游戏状态和棋盘 → 再做移动和碰撞检测 → 再做计分和食物生成 → 最后做开始/结束/重来的交互),这跟"先调研再开发再测试"是两条完全不同的轴。
40 41
41 拿不准时,宁可列得粗一点、少一点,也不要为了凑步骤数硬拆——一份没用的分解比没有分解更浪费下家的阅读时间。 42 拿不准时,宁可列得粗一点、少一点,也不要为了凑步骤数硬拆——一份没用的分解比没有分解更浪费下家的阅读时间。
  43 +
  44 +## 你可能会看到两种提示词(Phase 2,46 号文档)
  45 +
  46 +系统会先查一遍经验库(其他公司同角色沉淀下来的经验),查到相关的会拼进提示词的"已知相关经验"小节;查不到,或者你上一轮给了 `confidence:"low"`,系统会去做一次快速调研,再把结果拼进"调研参考"小节问你一次。这两段都只是**参考**,不是要你照抄——你的判断力仍然是主线,经验/调研信息不一致或跟这条 task 明显不搭时,按你自己的判断来,不强行采纳。