/** * goal 级验收 —— 在 goal 层再调一次 `org-validator`(27-P3C §四.10,用户定案) * * **不新造验收 agent**。用的还是 `system/agents/org-validator` 这个本体,只是换一个基数问它: * · task 级(既有,org graph 末节点):这**一个 task** 的 task-goal 达成了没有; * · goal 级(本模块):**N 个 task 合起来**,人提的那个需求达成了没有。 * 5 条 task 全 completed 只说明每件事都按各自的描述做完了,不说明拼起来是人要的东西 —— * 那个缺口正是 goal 层存在的理由,用别的东西替代不了。 * * 调用形态与 `task-planning` 相同(同一份注释在那边写全了,这里只记差异): * `AgentManager` 直调本体、**不在任何 graph run 里**、`onAcpEvent` 累加 * `session/result.usage`、`metadata.taskId` 传 goalId(否则 cwd 落 `_global/_no-task`)、 * 抛出前把已烧 token 挂在 `err.tokens` 上(`goals.validation_tokens` 唯一的来源)。 * * 与 `task-planning` 的三处实质差别: * * ① **绝不按需 hire**。`task-planning` 的 config.type 是 `meta-planning`,命中 * `NON_ORG_AGENT_TYPES`,hire 不写 `org_graph_nodes`;`org-validator` 的 type 是 * `validator`,**不在**那个豁免集合里 —— 在这里 hire 一次就等于往用户的组织图上加一个节点。 * 验收是只读动作,不该改公司的组织结构。所以:雇过就用 `cid:org-validator` * (吃到公司态记忆与 company-context),没雇过就用裸名调全局本体,两条都不写库。 * * ② **不重试**。拆分错了可以把 errors 回喂让它改;判词「格式没读懂」再问一次并不会让 * 结论更可信,只会再烧一次钱。解析不出来就按 §四.10 约束 4 保守收敛(调用方做), * 把原因写进 verdict,让界面看得见「这次没验成」。 * * ③ **本模块只回判词,不写库、不汇报**。goal 的状态、`validation_tokens`、OKR、 * `report_to` 唤醒全在 `task-settle.js` 一侧 —— 那里持有 CAS 闸门,副作用只能在那里做。 */ const path = require('path'); const REPO_ROOT = path.resolve(__dirname, '../..'); /** 全局唯一的一份验收本体(`system/agents/org-validator/`) */ const VALIDATOR_AGENT_ID = 'org-validator'; /** 判词落 `goals.verdict` 一列,界面直接显示。留长了没人读,也会把列表撑变形 */ const MAX_VERDICT_CHARS = 1500; /** 提示词里最多列几条产物。超过就截断并说明 —— 一个 goal 的产物索引可能有几百行 */ const MAX_ARTIFACTS = 60; // ──────────────────────────────────────────────────────────────── // 注入点(仿 task-planning.setPlanTasks / graph-engine.setPlanWorkGraph) // ──────────────────────────────────────────────────────────────── /** * 默认实现真的会起 Claude CLI 调 org-validator。这个注入点保留只为一件事: * 测试注入假判词,避免真起 CLI(慢、要钱、产出不确定)。 * * @type {(ctx: {companyId: string, goal: object, tasks: object[]}) => * Promise<{pass: boolean, verdict?: string, tokens?: number}>} */ let validateGoalImpl = defaultValidateGoal; /** 替换验收实现(传 null 回默认实现) */ function setValidateGoal(fn) { validateGoalImpl = fn || defaultValidateGoal; } /** 当前生效的验收实现。调用方一律走这里,不要直接调 defaultValidateGoal */ function validateGoal(ctx) { return validateGoalImpl(ctx); } // ──────────────────────────────────────────────────────────────── // 默认实现 // ──────────────────────────────────────────────────────────────── /** * 调 org-validator 判一次「这个 goal 达成了吗」。 * * @param {{companyId: string, goal: object, tasks: object[]}} ctx goal / tasks 都是库里的整行 * @returns {Promise<{pass: boolean, verdict: string, tokens: number}>} * @throws 调用炸了 / 判词读不出来;两种都在 error 上挂 `tokens`(已烧掉的那部分) */ async function defaultValidateGoal(ctx) { const AgentManager = require('../src/core/agent-manager'); // 空 config:AgentManager.getAgentConfig 有基于文件系统的动态寻址回退(本体恒在 // system/agents/{aid}),与 task-planning / agent-chat.js 的用法一致。 const manager = new AgentManager({}, REPO_ROOT); const agentName = resolveValidatorAgentName(ctx.companyId); let tokens = 0; const onAcpEvent = (ev) => { if (!ev || ev.method !== 'session/result') return; const u = (ev.params && ev.params.usage) || null; if (u) tokens += (u.input || 0) + (u.output || 0); }; let res; try { res = await manager.callAgent(agentName, buildGoalValidationPrompt(ctx), { onAcpEvent, metadata: { // taskId 决定 CLI 的 cwd(system/agents/org-validator/{cid}/{taskId}/)。 // goalId 是这里唯一天然的一次性标识:不传的话所有公司所有 goal 的验收共用一个目录。 taskId: ctx.goal.id, layer: 'goal-validate', goalId: ctx.goal.id, companyId: ctx.companyId, }, }); } catch (e) { e.tokens = (e.tokens || 0) + tokens; throw e; } const picked = extractAuditVerdict((res && res.output) || ''); if (!picked) { // 读不出判词 ≠ 判 fail。「格式没读懂」当成「需求没达成」会把一次真交付误杀成 failed, // 而 failed 是终态、写 OKR、唤醒上级 —— 误杀不可撤销。抛给调用方走保守收敛。 const err = new Error(`${VALIDATOR_AGENT_ID} 的回复里没有可解析的 audit_verdict(goal ${ctx.goal.id})`); err.tokens = tokens; throw err; } console.log(`[GoalValidation] goal ${ctx.goal.id} 判 ${picked.pass ? 'pass' : 'fail'}(${tokens} tokens)`); return { pass: picked.pass, verdict: picked.verdict, tokens }; } /** * 本体的寻址名。**只查不写**(见文件头 ①): * 雇过 → `cid:org-validator`(能读到公司态记忆 / company-context);没雇过 → 裸名调全局本体。 * * 判据查 `company_agents` 表而不是目录:公司私有目录在 28 批次 4 后被清空,按目录判恒为 false。 */ function resolveValidatorAgentName(companyId) { if (!companyId) return VALIDATOR_AGENT_ID; try { const { listCompanyAgents } = require('./company-service'); const hired = (listCompanyAgents(companyId) || []).some((r) => r.role_name === VALIDATOR_AGENT_ID); return hired ? `${companyId}:${VALIDATOR_AGENT_ID}` : VALIDATOR_AGENT_ID; } catch (e) { // 查不到就退回裸名:调用照样成立,只是少了公司态上下文,好过在这里把整次验收弄挂 console.warn(`[GoalValidation] 读 ${companyId} 的角色清单失败,退回裸名: ${e.message}`); return VALIDATOR_AGENT_ID; } } // ──────────────────────────────────────────────────────────────── // 提示词 // ──────────────────────────────────────────────────────────────── /** * §四.10 定的两项输入:goal.description(验收边界)+ 该 goal 全部 task 的产物索引。 * 外加 task 清单本身 —— 「哪几件事拼起来算达成」是这次判断的题面。 * * ⚠️ 开头那段「这次不是节点级验收」是必须的,不是客套:`org-validator/agent.md` 的 * 六步工作流整个建立在 run 级寻址上(`R=data/companies//runs/`、 * `acceptance.json`、`goal-contract.js run --run-id …`)。本次调用不在任何 run 里, * 那些路径一个都不存在。不明说,它会照本体正文去找那些文件、找不到就按 * 「契约不全 → input_underspecified → 立刻判 fail」收敛 —— 一次正常交付被误杀, * 而且它给的理由看上去还很有道理。 * * ⚠️ 同样**故意不写**任何写入边界段头:本次调用没有节点私有目录, * `PromptBuilder.buildRuntimeCoordinates` 靠那个字面量分叉,命中会让坐标声明不再给交付物归宿。 */ function buildGoalValidationPrompt(ctx) { const g = ctx.goal || {}; const tasks = ctx.tasks || []; return [ '# goal 级验收(不是节点级验收,先读完这一段)', '', '这次问你的是**一个 goal 整体达成了吗**:它下面 N 条 task 各自都跑完并判过了,', '现在要判「拼起来是不是人要的那个东西」。', '', '**本次调用不在任何 graph run 里**,所以:', '- 没有 `rootRunId`、没有 `runs//` 目录、没有 `acceptance.json` 契约文件;', '- **不要**去跑 `goal-contract.js validate / run`,也不要因为找不到契约就判' + ' `input_underspecified` —— 这次的验收边界就是下面 ① 那段需求原文,它本身就是契约;', '- **不要**写判决文件(没有节点目录可写)。你的结论只以回复末尾那个 JSON 块为准。', '', `## ① 验收边界 —— 人提的需求原文(goal ${g.id})`, g.description || '(调用方没给需求描述)', '', `- 标题:${g.title || '(无)'}`, `- 优先级:${g.priority || 'P1'}`, '', `## ② 这个 goal 拆成了哪几条 task(共 ${tasks.length} 条,均已 completed)`, describeTasks(tasks), '', '## ③ 这些 task 留下的产物索引', describeArtifacts(ctx.companyId, tasks), '', '## 怎么判', '', '逐条把 ① 的需求回扣到 ②③ 上:**需求里说到的每一件事,都能在产物里找到落点吗**。', '- 每条 task 都 completed 只说明「各自按各自的描述做完了」,**不构成 pass 的理由**;', '- 缺口不在某一条 task 内部,而在它们之间时(少了一块、两块对不上、合起来不是需求要的形状),', ' 判 `fail` 并在判词里点名**缺的是什么**,不要只说「未达成」;', '- 产物读不到、索引为空时:不许凭 task 描述想象产出。按证据不足判 `fail`,判词写明缺证据。', '', '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)', '```json', '{"audit_verdict": "pass", "verdict": "一到三句话:判了什么、依据是什么、不过的话缺什么"}', '```', '- `audit_verdict` 只能是 `pass` 或 `fail`(字段名不叫 `verdict`,沿用本体既有口径);', '- `verdict` 是给人看的判词,会原样存进 goal 行并显示在控制台,写具体、别写套话;', '- 分析过程写在 JSON 代码块**前面**,代码块之后不要再写任何字。', ].join('\n'); } /** task 清单:seq / 标题 / 描述。描述是每条 task 各自的验收边界,判「拼起来对不对」要靠它 */ function describeTasks(tasks) { if (!tasks.length) return '- (没有 task)'; return tasks .map((t) => `- [${t.seq}] ${t.title || '(无标题)'}\n ${String(t.description || '').replace(/\n/g, '\n ')}`) .join('\n'); } /** * 产物索引:`graph_files` 按 task_id 逐条查(§P4 §4.6 的表)。 * * ponytail: `findFiles` 一次只吃一个 taskId(`COALESCE(task_id,'') = ?` 等值匹配), * 所以这里是 N 次查询。N 是一个 goal 的 task 数(上限 8,`task-planning.MAX_TASKS`), * 不是行数,够不上 N+1 的量级。真嫌就在数据层加一个 `findFilesByTasks({companyId, taskIds})` * 用一条 `IN (…)` 出全部,口径与 `taskProgress` 的批量查询一致。 * * 给的是**绝对路径**(`companyRootDir` + `rel_path`):agent 的 cwd 在 * `system/agents/org-validator/{cid}/{goalId}/`,裸相对路径它一个都打不开,而且不报错。 */ function describeArtifacts(companyId, tasks) { let rows; try { const wdb = require('./work-graph-db'); const { companyRootDir } = require('./company-paths'); const root = companyRootDir(companyId); rows = tasks.flatMap((t) => wdb.findFiles({ companyId, taskId: t.id }).map((f) => ({ seq: t.seq, kind: f.kind, abs: path.join(root, f.rel_path), })) ); } catch (e) { return `- (读不到产物索引:${e.message})`; } if (!rows.length) { // 空索引是一条真信号,不是「这段就别写了」:它意味着这些 task 没登记任何产出。 return '- **产物索引为空**:这些 task 一份登记在案的产出都没有。这本身就是判断依据的一部分。'; } const shown = rows.slice(0, MAX_ARTIFACTS).map((r) => `- [task ${r.seq}] ${r.kind}:${r.abs}`); if (rows.length > MAX_ARTIFACTS) { shown.push(`- (共 ${rows.length} 条,只列前 ${MAX_ARTIFACTS} 条;其余在同一批目录下)`); } return shown.join('\n'); } // ──────────────────────────────────────────────────────────────── // 判词解析 // ──────────────────────────────────────────────────────────────── /** * 从回复里取出 `audit_verdict`。 * * **从后往前**取(同 `task-planning.extractPlannedTasks` 的理由):本体正文与分析过程里 * 常常引用判决样例,从前往后会认错那一份。 * * 兜底扫一遍字面量 `"audit_verdict": "pass|fail"`:JSON 块被写坏(尾逗号、注释)时, * 结论其实还在那儿。这一步只捞结论,判词就退回原文摘要。 * * @returns {{pass: boolean, verdict: string}|null} null = 连结论都没有,调用方走保守收敛 */ function extractAuditVerdict(output) { const text = String(output || ''); if (!text.trim()) return null; const candidates = jsonCandidates(text); for (let i = candidates.length - 1; i >= 0; i -= 1) { const doc = safeJsonParse(candidates[i]); const av = doc && typeof doc === 'object' ? String(doc.audit_verdict || '').toLowerCase() : ''; if (av === 'pass' || av === 'fail') { return { pass: av === 'pass', verdict: clampVerdict(doc.verdict || doc.remediation_note || `org-validator 判 ${av}`), }; } } const m = /"audit_verdict"\s*:\s*"(pass|fail)"/i.exec(text); if (m) { return { pass: m[1].toLowerCase() === 'pass', verdict: clampVerdict(`org-validator 判 ${m[1].toLowerCase()}(判词 JSON 未能解析,原文摘要:${text.slice(-400)})`), }; } return null; } /** ```json 围栏块,其次括号配平扫出的裸对象(与 task-planning 同款,含同款上限) */ function jsonCandidates(text) { const out = []; const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g; let m; while ((m = fence.exec(text))) out.push(m[1]); // ponytail: 配平扫描不理会字符串字面量里的括号,判词里带 `}` 时该候选解析失败、 // 退到下一个候选,不会崩。围栏路径覆盖绝大多数正常产出。 out.push(...balanced(text, '{', '}')); return out; } function balanced(text, open, close) { const out = []; let depth = 0; let start = -1; for (let i = 0; i < text.length; i += 1) { const c = text[i]; if (c === open) { if (depth === 0) start = i; depth += 1; } else if (c === close && depth > 0) { depth -= 1; if (depth === 0) out.push(text.slice(start, i + 1)); } } return out; } function safeJsonParse(s) { try { return JSON.parse(s); } catch { return null; } } function clampVerdict(v) { const s = typeof v === 'string' ? v.trim() : JSON.stringify(v); return s.length > MAX_VERDICT_CHARS ? `${s.slice(0, MAX_VERDICT_CHARS)}…` : s; } module.exports = { VALIDATOR_AGENT_ID, MAX_VERDICT_CHARS, validateGoal, setValidateGoal, // 下面两个导出是给测试用的(解析是这一层唯一的复杂逻辑,值得单独钉住) extractAuditVerdict, buildGoalValidationPrompt, };