/** * Goal Contract — 任务级验收契约的格式、校验、执行与判决持久化(P3 阶段 0 / T0) * * 设计见 docs/todo/system-design/27-goal-contract-format.md, * 需求见 27-p3-p4-work-graph-console.md §五 阶段 0 / §四.A.5 / §八.9, * 以及 27-e2e-task-execution-flow.md §十一(三个验收产出节点)。 * * 形态直接借 DevLoop 已跑通的 `integration/spec.json`(_spec/sdd-model.md M-6) * 与 `runs/result.json`(M-7/M-8):契约是一份 JSON、判决是一份 JSON, * 都放在约定好的固定位置,谁都不用猜。 * * §八.9 划的优先级:**外层(存哪、怎么被引用、怎么被执行、判决存哪)要定死, * 内层断言字段允许后续演进**。所以本文件的稳定面是: * * data/companies/{cid}/ * ├── tasks/{taskId}/ * │ └── acceptance.json ← 唯一契约入口(三 section,各自可为 null)【R19-B 后的落点】 * └── runs/{rootRunId}/ * ├── artifacts/ * │ ├── task-goal.json ← {companyId, taskId}:契约落点就是从这里推出来的 * │ ├── goal-benchmark.json ← 大块内容外置,契约里用 {"$ref": "..."} 指过来 * │ ├── goal-test-data/ * │ ├── goal-test-script/ * │ ├── acceptance.json ← 【存量】R19-B 之前的契约落点,只读兜底 * │ └── verdicts/ * │ ├── {verdictId}.json ← 一次执行一份完整判决 * │ └── index.ndjson ← append-only 索引,可查、可追溯到 run/node/attempt * └── nodes/{nodeRunId}/ ← output_ref 指向这里,即「被测产物」 * * ── R19-B:契约落点归并到 tasks/{taskId}/(27-P4 §4.6 的目录约定)────── * 契约是**任务级**事实、不是 run 级事实:同一个 task 可以跑好几个 root run(重试 / 续跑), * 契约必须跨 run 稳定,而且要能被 `graph_files`(kind='acceptance',`TASK_SHARED_KINDS`) * 索引到——那是 R-9.3「planner 生成、validator 执行」隔着一个 coder 也能拿到契约的**唯一**通路。 * 落在 `runs/{rootRunId}/artifacts/` 里的话,第二个 run 一来就找不着了,索引也无处挂 taskId。 * * **判决(verdicts/)不跟着搬**:一份判决回答的是「**这次 run 的这个节点的第几次尝试** * 达标没有」,它天生是 run 级的。搬到 task 级只会让不同 run 的 index.ndjson 挤在一条流里, * 而 `listVerdicts` 的 runId 过滤本来就是为「按 run 分」写的。 * * **存量兼容(读)**:`locateContract` 新路径优先、旧路径(`runs/{r}/artifacts/acceptance.json`) * 兜底;`$ref` / `test_script.path` 这类契约内相对路径两个根都找(模板仍教 agent 把 * `goal-benchmark.json` 写进 `$R/artifacts/`)。删兜底的时机见 27-goal-contract-format.md §八。 * * 与 output_ref 的关联方式:执行器的入参 targetPath 就是某个 graph_run_nodes.output_ref * 解析出的绝对路径;判决里回存 run_id / node_run_id / attempt / contract_hash 四元组, * 所以任何一份判决都能双向追溯到「哪次 run 的哪个节点的第几次尝试、用哪版契约判的」。 * * 不起 LLM:benchmark / test_data 是纯声明式比对,test_script 走真实子进程。 * * ── 生产调用方(R5-F3)───────────────────────────────────────────── * 契约的产出方和消费方都是 **agent**,不是引擎——见 27-goal-contract-format.md §二bis * 「为什么接在 agent 工具层而不是引擎钩子」。所以本文件除了作为模块被 require, * 还带一个 CLI 入口(文件末尾),供 agent 在 bash 里直接调: * * node server/services/goal-contract.js put --run-dir R --goal-id G --section benchmark --file X.json * node server/services/goal-contract.js validate --run-dir R * node server/services/goal-contract.js run --run-dir R --target T * node server/services/goal-contract.js list|show --run-dir R [...] * * 走 CLI 而不是让 agent 自己写 require 脚本,是因为 agent 侧唯一稳定的执行面就是 bash * (模板 tools.md 全是 bash 片段),且 `put --file` 让「绝不在 bash 里内联 JSON」这条 * 铁律得以遵守。 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { spawn } = require('child_process'); // contract_hash 直接复用 DevLoop 的实现(只读借用,不改它一行;见 §七「明确不改」) const { hashContract } = require('./devloop/doc-store'); const DATA_DIR = path.resolve(__dirname, '../../data'); /** run 目录根的覆盖,与 graph-engine.RUNS_ROOT / graph-run-sse 是同一个变量(测试隔离在用) */ const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null; const CONTRACT_VERSION = '1'; const CONTRACT_FILE = 'acceptance.json'; /** run 级身份卡:company.js 建 root run 时写的,带 {companyId, taskId} */ const TASK_GOAL_FILE = 'task-goal.json'; /** 契约在产物索引里的 kind —— 必须落在 graph-engine.TASK_SHARED_KINDS 里,否则下游查不到 */ const CONTRACT_KIND = 'acceptance'; const SECTIONS = ['benchmark', 'test_data', 'test_script']; // ponytail: v1 只支持 runner='node'(脚本退出码即判决),够表达一切断言。 // 上限:跑不了 vitest——实测仓库根 vitest.config 的 include 不覆盖 data/, // `vitest run <脚本>` / `--root` / `--dir` 都会被根 config 的 include 盖掉,直接 "No test files found"。 // 升级路径:真需要 vitest 时,为验收脚本生成一份临时 vitest.config 并用 `--config` 指过去, // 再把 'vitest' 加回这张白名单——契约的 runner 字段就是为此留的扩展位。 // 眼下 node 脚本里自己 spawn 任何测试框架也不受限制,所以不急。 const RUNNERS = ['node']; const MATCH_MODES = ['deep-equal', 'subset']; const DEFAULT_SCRIPT_TIMEOUT_MS = 120_000; const OUTPUT_CAP = 64 * 1024; // stdout/stderr 各留 64KB,超出截断 class ContractValidationError extends Error { constructor(errors) { super('goal contract validation failed'); this.name = 'ContractValidationError'; this.errors = errors; } } // ──────────────────────────────────────────────────────────────── // 路径 // ──────────────────────────────────────────────────────────────── /** root run 的产物根目录:data/companies/{cid}/runs/{rootRunId}/ */ function resolveRunDir(companyId, rootRunId) { return path.join(DATA_DIR, 'companies', String(companyId), 'runs', String(rootRunId)); } /** run 级共享产物区(跨 org node 只读,写入是特权动作,见 §四.A.5) */ function resolveArtifactsDir(runDir) { return path.join(runDir, 'artifacts'); } /** * 公司根 / 本 task 的产物目录 —— 镜像已收拢:唯一实现在 `company-paths.js`。 * 写的一侧(这里登记契约)与读的一侧(graph-engine 拼提示词)必须算出同一个字符串, * 算不出同一个就是索引指向空气且不报错。原先靠「两份逐字同构」的约定维持, * 控制台路由成为第三个调用方时那个约定就失效了。 */ const { companyRootDir, taskDirRel } = require('./company-paths'); /** * 从 run 目录读出 {companyId, taskId} —— 契约落点的唯一推导依据。 * 读不出来(旧 run / 手搓的目录)返回 null,调用方一律退回旧路径,不编一个假的 taskId。 */ function readRunIdentity(runDir) { if (!runDir) return null; const r = readJsonFile(path.join(resolveArtifactsDir(runDir), TASK_GOAL_FILE)); if (!r.ok || !r.json || typeof r.json !== 'object') return null; const companyId = r.json.companyId ? String(r.json.companyId) : null; if (!companyId) return null; return { companyId, taskId: r.json.taskId ? String(r.json.taskId) : null }; } /** 本 task 的产物目录(R19-B 的契约落点);推不出身份时返回 null */ function resolveTaskDir(runDir) { const id = readRunIdentity(runDir); if (!id) return null; return { ...id, dir: path.join(companyRootDir(id.companyId), taskDirRel(id.taskId)) }; } /** 契约目录:新路径(tasks/{taskId}/)优先,推不出来时退回旧的 artifacts/ */ function resolveContractDir(runDir) { const t = resolveTaskDir(runDir); return t ? t.dir : resolveArtifactsDir(runDir); } /** 契约**写入**路径(读要走 locateContract,它带旧路径兜底) */ function resolveContractPath(runDir) { return path.join(resolveContractDir(runDir), CONTRACT_FILE); } /** 契约的两个候选落点,新的在前。读取 / 相对路径解析都按这个顺序找。 */ function contractCandidates(runDir) { const primary = resolveContractPath(runDir); const legacy = path.join(resolveArtifactsDir(runDir), CONTRACT_FILE); return primary === legacy ? [primary] : [primary, legacy]; } /** 契约实际在哪:新路径优先,旧路径兜底(存量兼容)。都没有返回 null。 */ function locateContract(runDir) { return contractCandidates(runDir).find((p) => fs.existsSync(p)) || null; } /** * 契约内相对路径(`$ref` / `test_script.path` / `test_script.cwd`)的解析根,新的在前。 * 两个根都要给:契约搬到 tasks/{taskId}/ 了,但模板仍教 agent 把大块内容写进 `$R/artifacts/`。 */ function contractBases(runDir) { if (!runDir) return []; const dirs = [resolveContractDir(runDir), resolveArtifactsDir(runDir)]; return [...new Set(dirs.map((d) => path.resolve(d)))]; } /** 判决目录:**不跟着搬**(判决是 run 级事实,理由见文件头 R19-B 那段) */ function resolveVerdictsDir(runDir) { return path.join(resolveArtifactsDir(runDir), 'verdicts'); } /** * 把契约里的相对路径解析成绝对路径:在 `bases`(契约目录 + 旧 artifacts/)里挨个找, * 命中**存在的**那个;都不存在时返回第一个候选(好让报错信息指向新路径)。 * 逃出所有根的路径一律 null —— 防契约指向任意可执行文件。 */ function resolveInsideArtifacts(bases, rel) { const roots = (Array.isArray(bases) ? bases : [bases]).filter(Boolean).map((d) => path.resolve(d)); let first = null; for (const base of roots) { const abs = path.resolve(base, rel); if (abs !== base && !abs.startsWith(base + path.sep)) continue; // 逃逸的候选直接跳过 if (first === null) first = abs; if (fs.existsSync(abs)) return abs; } return first; } // ──────────────────────────────────────────────────────────────── // 小工具 // ──────────────────────────────────────────────────────────────── function now() { return new Date().toISOString(); } /** CONVENTIONS §2 的 ID 形态:vd-{YYYYMMDD-HHMM}-{4hex} */ function genVerdictId(d = new Date()) { const p2 = (n) => String(n).padStart(2, '0'); const stamp = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}` + `-${p2(d.getHours())}${p2(d.getMinutes())}`; return `vd-${stamp}-${crypto.randomBytes(2).toString('hex')}`; } /** key 排序的规范序列化,用于 deep-equal 比较 */ function stable(v) { if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null'; if (Array.isArray(v)) return '[' + v.map(stable).join(',') + ']'; return ( '{' + Object.keys(v) .sort() .map((k) => JSON.stringify(k) + ':' + stable(v[k])) .join(',') + '}' ); } function deepEqual(a, b) { return stable(a) === stable(b); } /** subset:expected 里出现的键/元素必须匹配,actual 允许有额外键 */ function matchSubset(expected, actual) { if (expected === null || typeof expected !== 'object') return expected === actual; if (Array.isArray(expected)) { if (!Array.isArray(actual) || actual.length !== expected.length) return false; return expected.every((e, i) => matchSubset(e, actual[i])); } if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return false; return Object.keys(expected).every((k) => matchSubset(expected[k], actual[k])); } function readJsonFile(abs) { try { return { ok: true, json: JSON.parse(fs.readFileSync(abs, 'utf8')) }; } catch (e) { return { ok: false, error: e.message }; } } /** CONVENTIONS §5:JSON 落盘走 tmp + rename */ function writeJsonAtomic(abs, json) { fs.mkdirSync(path.dirname(abs), { recursive: true }); const tmp = abs + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(json, null, 2)); fs.renameSync(tmp, abs); return abs; } // ──────────────────────────────────────────────────────────────── // 契约:读 / 写 / 校验 // ──────────────────────────────────────────────────────────────── /** * 校验契约本身是否合法(不碰被测产物)。 * * errors 形态与 org-graph-service.validateGraph 保持一致:{rule, detail}。 * rule 取值:schema | section | reference | runner * * @param {object} contract * @returns {{ok: boolean, errors: Array<{rule: string, detail: string}>}} */ function validateContract(contract) { const errors = []; const err = (rule, detail) => errors.push({ rule, detail }); if (!contract || typeof contract !== 'object' || Array.isArray(contract)) { return { ok: false, errors: [{ rule: 'schema', detail: 'contract must be a plain object' }] }; } if (contract.version !== CONTRACT_VERSION) { err('schema', `version must be "${CONTRACT_VERSION}", got ${JSON.stringify(contract.version)}`); } if (!contract.goal_id || typeof contract.goal_id !== 'string') { err('schema', 'missing field: goal_id'); } for (const k of Object.keys(contract)) { if (!['version', 'goal_id', 'created_by', 'created_at', ...SECTIONS].includes(k)) { err('schema', `unknown top-level field: ${k}`); } } // created_by 两种形态都合法:单写者写字符串;三个产出节点各写一段时是 {section: producer} 映射 // (§一.1「三个产出节点各写自己那一段」——一个字符串存不下三个作者)。审计字段,不参与判定。 if (contract.created_by !== undefined && contract.created_by !== null) { const cb = contract.created_by; const okShape = typeof cb === 'string' || (typeof cb === 'object' && !Array.isArray(cb) && Object.values(cb).every((v) => typeof v === 'string')); if (!okShape) err('schema', 'created_by must be a string or a {section: producer} object'); } // 至少一个 section 非 null。空契约会「平凡通过」——正是 §四.D.3 要求 planner-validator // 盯的那类漏洞,在格式层面直接堵掉。 const present = SECTIONS.filter((s) => contract[s] !== undefined && contract[s] !== null); if (present.length === 0) { err('section', 'at least one of benchmark / test_data / test_script must be non-null'); } for (const s of present) { const sec = contract[s]; if (typeof sec !== 'object' || Array.isArray(sec)) { err('section', `${s} must be an object or null`); continue; } // 未解引用的 $ref 只校验形态,内容等 readContract 解开后再校验 if (sec.$ref !== undefined) { if (typeof sec.$ref !== 'string' || !sec.$ref) err('reference', `${s}.$ref must be a non-empty string`); else if (path.isAbsolute(sec.$ref) || sec.$ref.split('/').includes('..')) { err('reference', `${s}.$ref must be a relative path inside artifacts/: ${sec.$ref}`); } continue; } if (s === 'benchmark') validateBenchmark(sec, err); if (s === 'test_data') validateTestData(sec, err); if (s === 'test_script') validateTestScript(sec, err); } return { ok: errors.length === 0, errors }; } function validateBenchmark(sec, err) { if (!Array.isArray(sec.dimensions) || sec.dimensions.length === 0) { err('section', 'benchmark.dimensions must be a non-empty array'); return; } let sum = 0; const seen = new Set(); for (const d of sec.dimensions) { if (!d || typeof d.name !== 'string' || !d.name) { err('section', 'benchmark.dimensions[].name required'); continue; } if (seen.has(d.name)) err('section', `benchmark duplicate dimension: ${d.name}`); seen.add(d.name); if (typeof d.weight !== 'number' || d.weight <= 0) err('section', `benchmark.${d.name}.weight must be > 0`); else sum += d.weight; if (d.pass_threshold !== undefined && typeof d.pass_threshold !== 'number') { err('section', `benchmark.${d.name}.pass_threshold must be a number`); } } if (seen.size > 0 && Math.abs(sum - 1) > 1e-6) { err('section', `benchmark dimension weights must sum to 1, got ${sum}`); } if (sec.overall_pass_threshold !== undefined && typeof sec.overall_pass_threshold !== 'number') { err('section', 'benchmark.overall_pass_threshold must be a number'); } } function validateTestData(sec, err) { if (!Array.isArray(sec.cases) || sec.cases.length === 0) { err('section', 'test_data.cases must be a non-empty array'); return; } if (sec.match !== undefined && !MATCH_MODES.includes(sec.match)) { err('section', `test_data.match must be one of ${MATCH_MODES.join('|')}`); } const seen = new Set(); for (const c of sec.cases) { if (!c || typeof c.id !== 'string' || !c.id) { err('section', 'test_data.cases[].id required'); continue; } if (seen.has(c.id)) err('section', `test_data duplicate case id: ${c.id}`); seen.add(c.id); if (!('expect' in c)) err('section', `test_data.cases[${c.id}].expect required`); } } function validateTestScript(sec, err) { if (!RUNNERS.includes(sec.runner)) { err('runner', `test_script.runner must be one of ${RUNNERS.join('|')}, got ${JSON.stringify(sec.runner)}`); } if (typeof sec.path !== 'string' || !sec.path) { err('section', 'test_script.path required'); } else if (path.isAbsolute(sec.path) || sec.path.split('/').includes('..')) { err('reference', `test_script.path must be a relative path inside artifacts/: ${sec.path}`); } if (sec.args !== undefined && !Array.isArray(sec.args)) err('section', 'test_script.args must be an array'); if (sec.timeout_ms !== undefined && (typeof sec.timeout_ms !== 'number' || sec.timeout_ms <= 0)) { err('section', 'test_script.timeout_ms must be a positive number'); } } /** * 解开 section 上的 $ref(相对 artifacts/,不许逃出去)。 * * contract_hash 一律按**解引用后**的契约计算——否则「内联写」和「$ref 外置写」 * 同一份验收标准会算出两个 hash,判决就追溯不回契约版本了。读和写都走这里。 */ function resolveRefs(contract, bases) { const out = { ...contract }; for (const s of SECTIONS) { const sec = out[s]; if (!sec || typeof sec !== 'object' || sec.$ref === undefined) continue; const abs = resolveInsideArtifacts(bases, sec.$ref); if (!abs) throw new ContractValidationError([{ rule: 'reference', detail: `${s}.$ref escapes artifacts/: ${sec.$ref}` }]); const sub = readJsonFile(abs); if (!sub.ok) throw new ContractValidationError([{ rule: 'reference', detail: `${s}.$ref unreadable: ${sec.$ref} (${sub.error})` }]); out[s] = sub.json; } return out; } /** * 读契约并解开 $ref。新路径优先、旧路径兜底(存量兼容)。 * @returns {{contract: object, path: string}|null} 没有契约文件返回 null */ function readContract(runDir) { const p = locateContract(runDir); if (!p) return null; const r = readJsonFile(p); if (!r.ok) throw new ContractValidationError([{ rule: 'schema', detail: `unreadable ${CONTRACT_FILE}: ${r.error}` }]); return { contract: resolveRefs(r.json, contractBases(runDir)), path: p }; } /** * 契约落盘后登记进 `graph_files`(kind='acceptance')。 * * 为什么登记写在这里、而不像节点产物那样由引擎在结算时扫目录(`indexNodeOutputs`): * 契约不是节点私有目录里的文件,引擎那一遍扫不到它;而这里是**写文件的同一个函数**, * 文件与索引在同一次调用里成对产生,索引不可能与实际文件漂移 * (§4.6「不靠 agent 自觉写」担心的漂移,在这个位置结构上就不成立)。 * * 登记失败不抛:文件已经落盘了,把索引失败当成契约写入失败会让 agent 反复重写一份好文件。 * 但**必须回给调用方**(CLI 会打出来),否则又是一处「落库这一环静默空掉」。 */ function indexContractFile(runDir, abs) { const id = readRunIdentity(runDir); if (!id) return { indexed: null, index_skipped: `没有 ${TASK_GOAL_FILE},推不出 companyId/taskId` }; const rel = path.relative(companyRootDir(id.companyId), abs); if (!rel || rel.startsWith('..')) return { indexed: null, index_skipped: `契约不在公司根下:${abs}` }; try { // 懒加载:只在真要登记时才开 sqlite —— validate / run / list 这些子命令不该为此起一个 DB 连接 const { recordFile } = require('./work-graph-db'); return { indexed: recordFile({ companyId: id.companyId, taskId: id.taskId, relPath: rel, kind: CONTRACT_KIND }), index_rel_path: rel, company_id: id.companyId, task_id: id.taskId, }; } catch (e) { return { indexed: null, index_error: e.message }; } } /** * 写契约(写前必校验,不通过抛 ContractValidationError;CONVENTIONS §12 同款约定)。 * 文件按原样落盘(保留 $ref),返回的 contract_hash 按解引用后的形态算。 * 因此 $ref 指向的文件必须先写好,否则这里就报错——比等到执行时才炸要早。 * * 落盘位置是 `tasks/{taskId}/acceptance.json`(R19-B),并同步登记进 `graph_files`。 */ function writeContract(runDir, contract) { const v = validateContract(contract); if (!v.ok) throw new ContractValidationError(v.errors); const bases = contractBases(runDir); const resolved = resolveRefs(contract, bases); const rv = validateContract(resolved); // 解引用后的内容同样要合法 if (!rv.ok) throw new ContractValidationError(rv.errors); const p = resolveContractPath(runDir); writeJsonAtomic(p, contract); return { path: p, contract_hash: hashContract(resolved), ...indexContractFile(runDir, p) }; } /** * 写**一段** section,合并进已有的 acceptance.json(产出侧唯一该用的入口)。 * * §一.1 的不变量是「三个产出节点(goal-benchmark / goal-test-data / goal-test-script) * 各写自己那一段,互不覆盖」,但 `writeContract` 是整份替换——三个节点先后调它, * 后写的会把先写的那两段抹掉,契约到了 org-validator 手上只剩最后一段。 * 这就是「产生 → 落库」之间被静默吞掉的那一环,所以产出侧必须走这个函数。 * * @param {string} runDir * @param {object} args {section, content, goalId, createdBy} * content 可以是内联对象,也可以是 {"$ref": "goal-benchmark.json"} * @returns {{path: string, contract_hash: string, sections: string[]}} */ function writeContractSection(runDir, { section, content, goalId, createdBy } = {}) { if (!SECTIONS.includes(section)) { throw new ContractValidationError([ { rule: 'schema', detail: `section must be one of ${SECTIONS.join('|')}, got ${JSON.stringify(section)}` }, ]); } if (!content || typeof content !== 'object' || Array.isArray(content)) { throw new ContractValidationError([{ rule: 'section', detail: `${section} content must be a plain object` }]); } // 读**原文**(不解 $ref):合并要保留别人写的 $ref 形态,解开再写回等于把外置内容抄进契约。 // 读走 locateContract(旧路径兜底)、写走 resolveContractPath(新路径):R19-B 之前 // 已经写在旧路径上的段,第一次 put 时会被搬到新路径,而不是被一份新契约盖掉。 const p = locateContract(runDir); let base = {}; if (p) { const r = readJsonFile(p); if (!r.ok) throw new ContractValidationError([{ rule: 'schema', detail: `unreadable ${CONTRACT_FILE}: ${r.error}` }]); base = r.json && typeof r.json === 'object' && !Array.isArray(r.json) ? r.json : {}; } const nextGoal = goalId || base.goal_id; if (base.goal_id && goalId && base.goal_id !== goalId) { // 一个 root run 一份契约、一个 goal(§一.1)。串了 goal 说明调用方拿错了 runDir, // 静默覆盖会让判决追溯到一个根本不是它的 goal_id 上。 throw new ContractValidationError([ { rule: 'schema', detail: `goal_id mismatch: existing ${base.goal_id}, incoming ${goalId}` }, ]); } const merged = { ...base, version: CONTRACT_VERSION, goal_id: nextGoal, created_at: base.created_at || now(), // 保留首写时间:contract_hash 含它,刷新会让 hash 无谓变动 [section]: content, }; if (createdBy) { const prev = typeof base.created_by === 'string' ? {} : (base.created_by || {}); merged.created_by = { ...prev, [section]: createdBy }; } const out = writeContract(runDir, merged); return { ...out, sections: SECTIONS.filter((s) => merged[s] != null) }; } // ──────────────────────────────────────────────────────────────── // 执行器 // ──────────────────────────────────────────────────────────────── /** * 短路失败(还没跑到逐维度/逐 case 比对就已经判定不通过)的 failures 条目。 * * 形状与正常比对产出的 failure 完全一致(case / expected / got / hint), * 这样 `executeContract` 的 `failures: active.flatMap(...)` 和 CLI 的 `fmtFailures` * 都不用为短路分支分叉——**下游只认一种形状**。 * `hint` 里带上 `reason`,让扁平汇总单看一行也能归因。 */ function shortCircuitFailure(caseName, reason, detail, expected) { return { case: caseName, expected, got: detail === undefined || detail === null ? null : String(detail), hint: `${reason}: ${SHORT_CIRCUIT_HINTS[reason] || '执行前置条件不满足,未进入逐项比对'}`, }; } const SHORT_CIRCUIT_HINTS = { target_not_json: '被测产物读不出 JSON(不存在 / 不是合法 JSON),无法比对', script_missing: '契约指向的验收脚本不在 artifacts/ 下,未起进程', script_escapes_artifacts: '验收脚本路径逃出 artifacts/,拒绝执行', }; /** * benchmark:被测产物提供各维度分数,执行器只做加权与阈值比较(纯声明式,不起 LLM)。 * 被测产物形态:{"scores": {"": 87, ...}} 或直接 {"": 87, ...} */ function runBenchmark(sec, target) { // 短路分支也必须进 failures:verdict.failures 是下游(validator agent / validator-triager) // 唯一的扁平归因入口,只留 reason 不留 failures 的话「读回」这一环就空了(§十 四环缺陷)。 if (!target.ok) { return { ok: false, reason: 'target_not_json', detail: target.error, score: null, dimensions: [], failures: [shortCircuitFailure('benchmark', 'target_not_json', target.error, '可解析为 JSON 的被测产物')], }; } const scores = (target.json && typeof target.json.scores === 'object' && target.json.scores) || target.json || {}; const dims = []; let weighted = 0; let ok = true; for (const d of sec.dimensions) { const score = scores[d.name]; const has = typeof score === 'number'; const threshold = typeof d.pass_threshold === 'number' ? d.pass_threshold : null; const passed = has && (threshold === null || score >= threshold); if (!passed) ok = false; if (has) weighted += score * d.weight; dims.push({ name: d.name, weight: d.weight, score: has ? score : null, pass_threshold: threshold, ok: passed, reason: has ? undefined : 'missing score in target', }); } const overall = typeof sec.overall_pass_threshold === 'number' ? sec.overall_pass_threshold : null; const overallOk = overall === null || weighted >= overall; if (!overallOk) ok = false; return { ok, score: Number(weighted.toFixed(4)), overall_pass_threshold: overall, overall_ok: overallOk, dimensions: dims, failures: dims.filter((d) => !d.ok).map((d) => ({ case: d.name, expected: d.pass_threshold, got: d.score, hint: d.reason || 'dimension score below pass_threshold', })), }; } /** * test_data:被测产物提供各 case 的实际输出,执行器逐 case 比对(纯声明式,不起 LLM)。 * 被测产物形态:{"results": {"": }} 或 {"results": [{"id":..,"output":..}]} */ function runTestData(sec, target) { if (!target.ok) { return { ok: false, reason: 'target_not_json', detail: target.error, match: sec.match || 'deep-equal', total: sec.cases.length, passed: 0, failed: sec.cases.length, failures: [shortCircuitFailure('test_data', 'target_not_json', target.error, '可解析为 JSON 的被测产物')], }; } const raw = (target.json && target.json.results !== undefined ? target.json.results : target.json) || {}; const actual = Array.isArray(raw) ? Object.fromEntries(raw.map((r) => [r && r.id, r && (r.output !== undefined ? r.output : r.actual)])) : raw; const mode = sec.match || 'deep-equal'; const failures = []; for (const c of sec.cases) { const got = actual[c.id]; const missing = !(c.id in actual); const passed = !missing && (mode === 'subset' ? matchSubset(c.expect, got) : deepEqual(c.expect, got)); if (!passed) { failures.push({ case: c.id, expected: c.expect, got: missing ? null : got, hint: missing ? `no result for case ${c.id} in target` : `case ${c.id} mismatch (${mode})`, }); } } return { ok: failures.length === 0, match: mode, total: sec.cases.length, passed: sec.cases.length - failures.length, failed: failures.length, failures, }; } /** 起子进程跑脚本;超时先 SIGTERM 再 SIGKILL(沿用 integrator-tools.killAll 的做法) */ function spawnScript({ cmd, args, cwd, env, timeoutMs }) { return new Promise((resolve) => { const started = Date.now(); let stdout = ''; let stderr = ''; let timedOut = false; const child = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); const cap = (buf, chunk) => (buf.length >= OUTPUT_CAP ? buf : buf + String(chunk).slice(0, OUTPUT_CAP - buf.length)); child.stdout.on('data', (c) => { stdout = cap(stdout, c); }); child.stderr.on('data', (c) => { stderr = cap(stderr, c); }); const killer = setTimeout(() => { timedOut = true; try { child.kill('SIGTERM'); } catch { /* already gone */ } setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already gone */ } }, 3000).unref(); }, timeoutMs); killer.unref(); child.on('error', (e) => { clearTimeout(killer); resolve({ exit_code: null, spawn_error: e.message, stdout, stderr, timed_out: false, duration_ms: Date.now() - started }); }); child.on('close', (code, signal) => { clearTimeout(killer); resolve({ exit_code: code, signal: signal || null, stdout, stderr, timed_out: timedOut, duration_ms: Date.now() - started }); }); }); } /** * test_script:真实子进程执行(DevLoop Integrator 起真实进程跑 e2e 的同款做法)。 * 被测产物路径通过 env ACCEPTANCE_TARGET 交给脚本,脚本退出码即判决。 */ async function runTestScript(sec, { bases, artifactsDir, runDir, targetPath }) { const scriptAbs = resolveInsideArtifacts(bases, sec.path); if (!scriptAbs) { return { ok: false, reason: 'script_escapes_artifacts', detail: sec.path, runner: sec.runner, script: sec.path, exit_code: null, failures: [shortCircuitFailure(sec.path, 'script_escapes_artifacts', sec.path, 'artifacts/ 之内的相对脚本路径')], }; } if (!fs.existsSync(scriptAbs)) { return { ok: false, reason: 'script_missing', detail: scriptAbs, runner: sec.runner, script: sec.path, exit_code: null, failures: [shortCircuitFailure(sec.path, 'script_missing', scriptAbs, '契约指向的脚本文件存在')], }; } const res = await spawnScript({ cmd: process.execPath, // RUNNERS 白名单目前只有 'node' args: [scriptAbs, ...(sec.args || [])], cwd: sec.cwd ? resolveInsideArtifacts(bases, sec.cwd) || runDir : runDir, env: { ...process.env, ACCEPTANCE_TARGET: targetPath, ACCEPTANCE_RUN_DIR: runDir || '', ACCEPTANCE_ARTIFACTS_DIR: artifactsDir || '', }, timeoutMs: sec.timeout_ms || DEFAULT_SCRIPT_TIMEOUT_MS, }); const expectCode = (sec.pass_on && typeof sec.pass_on.exit_code === 'number') ? sec.pass_on.exit_code : 0; const ok = !res.timed_out && !res.spawn_error && res.exit_code === expectCode; return { ok, runner: sec.runner, script: sec.path, exit_code: res.exit_code, signal: res.signal, timed_out: res.timed_out, duration_ms: res.duration_ms, stdout_excerpt: res.stdout.slice(-4000), stderr_excerpt: res.stderr.slice(-4000), reason: res.timed_out ? 'script_timeout' : (res.spawn_error ? 'spawn_error' : undefined), failures: ok ? [] : [{ case: sec.path, expected: `exit ${expectCode}`, got: res.timed_out ? 'timeout' : (res.spawn_error || `exit ${res.exit_code}`), hint: (res.stderr || res.stdout).slice(-800), }], }; } /** * 基础设施抖动 → infra_flake(§四.D.2.2 的取值域)。 * 这类失败在引擎侧原地重跑、不烧任何计数,所以必须与「真的没达标」区分开。 */ const INFRA_REASONS = new Set(['script_timeout', 'spawn_error']); /** * 执行一份契约,产出判决(纯执行,不落盘)。 * * @param {object} contract 已解引用的契约(readContract 的产物 / 手写对象) * @param {string} targetPath 被测产物绝对路径(通常来自 graph_run_nodes.output_ref) * @param {object} [opts] {runDir, runId, nodeRunId, attempt} * @returns {Promise} verdict */ async function executeContract(contract, targetPath, opts = {}) { const started = Date.now(); const runDir = opts.runDir || null; const bases = contractBases(runDir); // 契约所在目录(新路径优先)—— 脚本拿到的 ACCEPTANCE_ARTIFACTS_DIR 就是它 const artifactsDir = bases[0] || null; // 相对路径先归一:下面既拿它做 startsWith 判断又拿它做 path.relative, // 两处用不同形态会算出「判断说在 run 目录里、relative 却算出一串 ../」的结果。 const targetAbs = path.resolve(targetPath); const base = { verdict_id: genVerdictId(), goal_id: contract && contract.goal_id, contract_hash: null, run_id: opts.runId || null, node_run_id: opts.nodeRunId || null, // ⚠️ 不能写 `opts.attempt || null`:attempt 是 **0 基**(graph-engine 的 picked.attempt 从 0 起), // 第一次尝试的 0 会被 `||` 判成假值吞掉,判决四元组当场少一环、追溯不回是哪次尝试。 attempt: Number.isInteger(opts.attempt) ? opts.attempt : null, // 落在 run 目录里的产物存相对路径(整个 run 目录可整体搬走 / 归档后仍可读), // 外部路径原样存绝对路径 target: runDir && targetAbs.startsWith(path.resolve(runDir) + path.sep) ? path.relative(path.resolve(runDir), targetAbs) : targetAbs, created_at: now(), }; const v = validateContract(contract); if (!v.ok) { // 契约本身不合法:不是产物的错,归因为「输入约束没写清」,退回契约产出者。 return { ...base, ok: false, fail_reason: 'input_underspecified', sections: { benchmark: null, test_data: null, test_script: null }, failures: v.errors.map((e) => ({ case: e.rule, expected: 'valid contract', got: e.detail, hint: 'fix acceptance.json' })), duration_ms: Date.now() - started, }; } base.contract_hash = hashContract(contract); const needsJson = contract.benchmark || contract.test_data; const target = needsJson ? (fs.existsSync(targetAbs) ? readJsonFile(targetAbs) : { ok: false, error: `target not found: ${targetAbs}` }) : { ok: true, json: null }; const sections = { benchmark: null, test_data: null, test_script: null }; if (contract.benchmark) sections.benchmark = runBenchmark(contract.benchmark, target); if (contract.test_data) sections.test_data = runTestData(contract.test_data, target); if (contract.test_script) { // 脚本拿到的 ACCEPTANCE_TARGET 必须是绝对路径:子进程的 cwd 是 runDir/artifacts 下的某处, // 传相对路径过去它解析出来的是另一个文件。 sections.test_script = await runTestScript(contract.test_script, { bases, artifactsDir, runDir, targetPath: targetAbs, }); } const active = SECTIONS.map((s) => sections[s]).filter(Boolean); const ok = active.every((s) => s.ok); const infra = active.some((s) => INFRA_REASONS.has(s.reason)); return { ...base, ok, // 判决本身只回答「这次验收有没有达成」,**不**寻址任何节点:它是机器执行的合同校验, // 手上只有 target 与 sections,没有图的信息。跨层定点(target_node → fail_node_ref) // 由下游 validator-triager 依据这里的 failures 归因产出(R-17.1 定案;原 R-16 // 「回退只退到 org node 粒度」的假设已作废,引擎现在支持退到子图里的具体 node)。 fail_reason: ok ? null : (infra ? 'infra_flake' : 'acceptance_not_met'), sections, // 扁平汇总,供下游 validator-triager 归因直接消费(R-17.2 定名) failures: active.flatMap((s) => s.failures || []), duration_ms: Date.now() - started, }; } // ──────────────────────────────────────────────────────────────── // 判决持久化 // ──────────────────────────────────────────────────────────────── /** * 落盘一份判决:完整体写 verdicts/{verdictId}.json,摘要 append 到 verdicts/index.ndjson。 * index 永不改已写行(CONVENTIONS §12 的 ndjson 不变量)。 */ function persistVerdict(runDir, verdict) { const dir = resolveVerdictsDir(runDir); const p = path.join(dir, `${verdict.verdict_id}.json`); writeJsonAtomic(p, verdict); const indexPath = path.join(dir, 'index.ndjson'); const entry = { verdict_id: verdict.verdict_id, ok: verdict.ok, fail_reason: verdict.fail_reason || null, goal_id: verdict.goal_id || null, run_id: verdict.run_id || null, node_run_id: verdict.node_run_id || null, // 同 executeContract:attempt 0 基,`||` 会把第一次尝试吞成 null, // 于是 listVerdicts 读回来的索引里首轮判决永远查不到 attempt。 attempt: Number.isInteger(verdict.attempt) ? verdict.attempt : null, contract_hash: verdict.contract_hash || null, target: verdict.target || null, created_at: verdict.created_at, file: path.relative(runDir, p), }; fs.appendFileSync(indexPath, JSON.stringify(entry) + '\n'); return { path: p, indexPath, entry }; } /** 查判决索引(可按 run / node / 只看失败过滤),新的在后,limit 取末尾 N 条 */ function listVerdicts(runDir, filter = {}) { const indexPath = path.join(resolveVerdictsDir(runDir), 'index.ndjson'); if (!fs.existsSync(indexPath)) return []; let rows = fs .readFileSync(indexPath, 'utf8') .split('\n') .filter(Boolean) .map((l) => { try { return JSON.parse(l); } catch { return null; } }) .filter(Boolean); if (filter.runId) rows = rows.filter((r) => r.run_id === filter.runId); if (filter.nodeRunId) rows = rows.filter((r) => r.node_run_id === filter.nodeRunId); if (filter.onlyFailed) rows = rows.filter((r) => !r.ok); if (filter.limit) rows = rows.slice(-filter.limit); return rows; } /** 读单份完整判决 */ function readVerdict(runDir, verdictId) { const p = path.join(resolveVerdictsDir(runDir), `${verdictId}.json`); if (!fs.existsSync(p)) return null; const r = readJsonFile(p); return r.ok ? r.json : null; } /** * 一站式入口:读 runDir 的契约 → 执行 → 落盘判决。引擎侧的 validator 节点调这个。 * * @param {object} args {runDir, targetPath, runId, nodeRunId, attempt} * @returns {Promise<{verdict: object, path: string}>} */ async function runAcceptance({ runDir, targetPath, runId, nodeRunId, attempt }) { const loaded = readContract(runDir); if (!loaded) { // 两个候选都列出来:agent 看到「新路径没有、旧路径也没有」才知道是压根没人 put 过 throw new ContractValidationError([ { rule: 'reference', detail: `no ${CONTRACT_FILE} at any of: ${contractCandidates(runDir).join(' | ')}` }, ]); } const verdict = await executeContract(loaded.contract, targetPath, { runDir, runId, nodeRunId, attempt }); const { path: p } = persistVerdict(runDir, verdict); return { verdict, path: p }; } // ──────────────────────────────────────────────────────────────── // CLI(agent 侧的生产入口,见文件顶部「生产调用方」) // ──────────────────────────────────────────────────────────────── const USAGE = `goal-contract — 验收契约的产出 / 校验 / 执行 / 查询(27-goal-contract-format.md) put --run-dir --section (--file | --ref ) [--goal-id ] [--created-by <节点本体名>] 产出侧:把**一段** section 合并进契约,不覆盖别人写的段。 落点是 data/companies/{cid}/tasks/{taskId}/acceptance.json(从 --run-dir 的 artifacts/task-goal.json 推出来),并同步登记进 graph_files(kind=acceptance)。 --ref / test_script.path 这类相对路径两个根都找:契约目录 + 旧的 {run-dir}/artifacts/。 validate --run-dir 只校验契约本身(空契约 / 权重不足 1 / runner 越界 / 路径逃逸都在这里被挡)。 run --run-dir --target <被测产物路径> [--run-id ] [--node-run-id ] [--attempt ] [--json] 消费侧:读契约 → 执行 → 落盘判决。缺省的 run-dir / node-run-id / attempt 会从 target 路径推。 list --run-dir [--node-run-id ] [--only-failed] [--limit ] show --run-dir --verdict-id 退出码:0 = 通过 / 操作成功;1 = **判决为 fail**(工具本身是好的);2 = 跑不出判决(参数错 / 没契约 / 契约非法)。 `; /** * 从被测产物路径向上找 run 目录(agent 少填一个参数少错一次)。 * * ⚠️ 认的标记有两个:`artifacts/task-goal.json`(新,R19-B 之后契约已经不在 run 目录里了) * 与 `artifacts/acceptance.json`(旧存量)。只认后者的话,契约一搬走, * `coder-validator` / `org-validator` 那句不带 `--run-dir` 的 `run --target …` 当场推不出目录 * ——把契约挪走却没同步认路的标记,就是「改路径把读回那一环弄断」的典型。 */ const RUN_DIR_MARKERS = [TASK_GOAL_FILE, CONTRACT_FILE]; function deriveRunDir(targetPath) { let dir = path.dirname(path.resolve(targetPath)); for (let i = 0; i < 12; i++) { if (RUN_DIR_MARKERS.some((m) => fs.existsSync(path.join(dir, 'artifacts', m)))) return dir; const up = path.dirname(dir); if (up === dir) break; dir = up; } return null; } /** .../nodes/{nodeRunId}/attempt-{n}.json → {nodeRunId, attempt};推不出来就留 null */ function deriveTargetIds(targetPath) { const abs = path.resolve(targetPath); const parts = abs.split(path.sep); const i = parts.lastIndexOf('nodes'); const nodeRunId = i >= 0 && parts[i + 1] ? parts[i + 1] : null; const m = /^attempt-(\d+)\./.exec(path.basename(abs)); return { nodeRunId, attempt: m ? Number(m[1]) : null }; } function fmtFailures(failures) { const rows = failures || []; const out = rows.slice(0, 20).map( (f) => ` - ${f.case}: expected=${JSON.stringify(f.expected)} got=${JSON.stringify(f.got)}${f.hint ? ` | ${f.hint}` : ''}`, ); if (rows.length > 20) out.push(` … 另有 ${rows.length - 20} 条,完整清单见 verdict 文件`); // 判决为 fail 却一条 failure 都没有,本身就是要报出来的异常(旧版直接打个空的 `failures:` 标题, // agent 看到的是「fail 但没有任何可归因信息」,分不清「工具吞了」还是「真的没细节」)。 if (rows.length === 0) out.push(' (无逐项明细——看上面的 sections 行与 fail_reason)'); return out; } /** * 一段 section 的人读摘要。 * * ⚠️ **短路分支没有 score / passed / exit_code**(`target_not_json` / `script_missing` / * `script_escapes_artifacts` 直接 return,压根没跑到比对),旧版无条件打 * `score=${s.benchmark.score}`,于是照着 `tools.md` 退出码表办事的 agent 只看到 * 「benchmark=FAIL、score=undefined、failures 空」——而落盘的 verdict JSON 里 * reason 和 detail 明明白白都在。**归因信息被吞在「读回」这一环**(§十 的四环缺陷模式)。 * 所以这里按字段是否真的存在分别打,且**只要有 reason 就一定把 reason + detail 打出来**。 */ function fmtSection(name, sec) { if (!sec) return []; const out = []; if (name === 'benchmark' && typeof sec.score === 'number') { out.push(` benchmark score=${sec.score} / ${sec.overall_pass_threshold ?? '-'}`); } if (name === 'test_data' && typeof sec.total === 'number') { out.push(` test_data ${sec.passed}/${sec.total} passed (match=${sec.match})`); } // duration_ms 只有真起过进程才有:短路分支打一行 `exit=null` 会被读成「跑了、返回 null」 if (name === 'test_script' && sec.duration_ms !== undefined) { out.push(` test_script exit=${sec.exit_code} timed_out=${sec.timed_out === true} (${sec.duration_ms}ms)`); } if (sec.reason) { out.push(` ${name} reason=${sec.reason}` + (sec.detail === undefined || sec.detail === null ? '' : ` detail=${sec.detail}`)); } if (name === 'test_script' && sec.stderr_excerpt) out.push(` stderr: ${sec.stderr_excerpt.slice(-500)}`); if (out.length === 0) out.push(` ${name} ${sec.ok ? 'ok' : 'FAIL'}(判决文件里也没有更多明细)`); return out; } async function cliMain(argv) { const cmd = argv[0]; if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { process.stdout.write(USAGE); return cmd ? 0 : 2; } const { values: a } = require('util').parseArgs({ args: argv.slice(1), allowPositionals: false, options: { 'run-dir': { type: 'string' }, target: { type: 'string' }, 'goal-id': { type: 'string' }, section: { type: 'string' }, file: { type: 'string' }, ref: { type: 'string' }, 'created-by': { type: 'string' }, 'run-id': { type: 'string' }, 'node-run-id': { type: 'string' }, attempt: { type: 'string' }, 'verdict-id': { type: 'string' }, 'only-failed': { type: 'boolean' }, limit: { type: 'string' }, json: { type: 'boolean' }, }, }); const needRunDir = () => { const d = a['run-dir'] || (a.target ? deriveRunDir(a.target) : null); if (!d) throw new Error('missing --run-dir (且无法从 --target 推出 run 目录)'); return d; }; if (cmd === 'put') { const runDir = needRunDir(); if (!a.file && !a.ref) throw new Error('put 需要 --file 或 --ref 其一'); const content = a.ref ? { $ref: a.ref } : JSON.parse(fs.readFileSync(a.file, 'utf8')); const r = writeContractSection(runDir, { section: a.section, content, goalId: a['goal-id'], createdBy: a['created-by'], }); // 产物索引这一行必须打:契约进不了 graph_files,下游 validator 的提示词里就不会出现它 // (引擎的 describeInputs 只认索引),而 put 本身照样退出 0——不打就是又一处静默空环。 const idx = r.indexed ? `graph_files: ${r.indexed} kind=${CONTRACT_KIND} rel=${r.index_rel_path} task=${r.task_id || '-'}` : `graph_files: 未登记(${r.index_error || r.index_skipped})—— 下游 validator 的提示词里不会自动出现这份契约`; process.stdout.write(`OK ${a.section} 已写入 ${r.path}\n` + `contract_hash: ${r.contract_hash}\nsections: ${r.sections.join(', ') || '(空)'}\n${idx}\n`); return 0; } if (cmd === 'validate') { const runDir = needRunDir(); const loaded = readContract(runDir); // $ref 解不开会在这里抛,正是要挡的 if (!loaded) throw new Error(`没有 ${CONTRACT_FILE},找过:${contractCandidates(runDir).join(' | ')}`); const v = validateContract(loaded.contract); const present = SECTIONS.filter((s) => loaded.contract[s] != null); if (v.ok) { process.stdout.write(`OK 契约合法:${loaded.path}\nsections: ${present.join(', ')}\n` + `contract_hash: ${hashContract(loaded.contract)}\n`); return 0; } process.stdout.write('INVALID 契约不合法(= fail_reason: input_underspecified,退回契约产出者)\n' + v.errors.map((e) => ` - [${e.rule}] ${e.detail}`).join('\n') + '\n'); return 1; } if (cmd === 'run') { if (!a.target) throw new Error('run 需要 --target <被测产物路径>'); const runDir = needRunDir(); const derived = deriveTargetIds(a.target); const attempt = a.attempt !== undefined ? Number(a.attempt) : derived.attempt; const { verdict, path: vp } = await runAcceptance({ runDir, targetPath: a.target, runId: a['run-id'] || null, nodeRunId: a['node-run-id'] || derived.nodeRunId, attempt: Number.isInteger(attempt) ? attempt : null, }); if (a.json) { process.stdout.write(JSON.stringify(verdict, null, 2) + '\n'); } else { const s = verdict.sections; const lines = [ `VERDICT ${verdict.ok ? 'pass' : 'fail'} fail_reason=${verdict.fail_reason || 'null'}` + ` goal=${verdict.goal_id || '?'} attempt=${verdict.attempt === null ? '?' : verdict.attempt}`, `verdict_id: ${verdict.verdict_id} contract_hash: ${verdict.contract_hash || '?'}`, `verdict_file: ${vp}`, `target: ${verdict.target}`, `sections: ${SECTIONS.map((k) => (s[k] ? `${k}=${s[k].ok ? 'ok' : 'FAIL'}` : `${k}=-`)).join(' ')}`, ]; for (const k of SECTIONS) lines.push(...fmtSection(k, s[k])); if (!verdict.ok) lines.push('failures:', ...fmtFailures(verdict.failures)); process.stdout.write(lines.join('\n') + '\n'); } return verdict.ok ? 0 : 1; } if (cmd === 'list') { const runDir = needRunDir(); const rows = listVerdicts(runDir, { nodeRunId: a['node-run-id'], runId: a['run-id'], onlyFailed: !!a['only-failed'], limit: a.limit ? Number(a.limit) : undefined, }); process.stdout.write(rows.length ? rows.map((r) => `${r.created_at} ${r.ok ? 'pass' : 'fail'} ${r.verdict_id}` + ` node=${r.node_run_id || '-'} attempt=${r.attempt === null ? '-' : r.attempt}` + ` ${r.fail_reason || ''}`).join('\n') + '\n' : '(没有判决记录)\n'); return 0; } if (cmd === 'show') { const runDir = needRunDir(); const v = readVerdict(runDir, a['verdict-id']); if (!v) throw new Error(`没有这份判决:${a['verdict-id']}`); process.stdout.write(JSON.stringify(v, null, 2) + '\n'); return v.ok ? 0 : 1; } throw new Error(`未知子命令:${cmd}\n\n${USAGE}`); } if (require.main === module) { cliMain(process.argv.slice(2)) .then((code) => process.exit(code)) .catch((e) => { // 契约非法 / 参数错 / 没契约 → 2:与「判决为 fail」的 1 分开, // 否则 agent 分不清「产物没达标」和「工具压根没跑起来」(后者是 input_underspecified)。 const detail = e instanceof ContractValidationError ? e.errors.map((x) => ` - [${x.rule}] ${x.detail}`).join('\n') : ''; process.stderr.write(`ERROR ${e.message}\n${detail}${detail ? '\n' : ''}`); process.exit(2); }); } module.exports = { // 常量 CONTRACT_VERSION, CONTRACT_FILE, CONTRACT_KIND, SECTIONS, RUNNERS, ContractValidationError, // 路径 resolveRunDir, resolveArtifactsDir, resolveContractPath, resolveVerdictsDir, // R19-B:契约落点 tasks/{taskId}/ + 旧路径兜底 companyRootDir, taskDirRel, readRunIdentity, resolveTaskDir, resolveContractDir, contractCandidates, locateContract, // 契约 validateContract, readContract, writeContract, writeContractSection, resolveRefs, contractHash: hashContract, // 入参必须是**已解引用**的契约 // 执行 executeContract, runAcceptance, // 判决 persistVerdict, listVerdicts, readVerdict, // CLI(测试直接跑它,保证模板里印的那几行命令是真能跑的) cliMain, deriveRunDir, deriveTargetIds, };