goal-validation.js
16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
/**
* 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/<cid>/runs/<rootRunId>`、
* `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/<rootRunId>/` 目录、没有 `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,
};