goal-contract.js 57.6 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 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253
/**
 * 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');

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}/`。
 *
 * 形状必须与 `graph-engine.runRootDir` **逐字一致**:run 目录是产物、stream.log、
 * timeline.ndjson 的共同落点,两处算出不同的字符串就是「文件在、登记也在,只是两边
 * 说的不是一个地方」——而且不报错。覆盖态下**扁平挂在 `{RUNS_ROOT}/{rootRunId}`、
 * 没有公司段**,因为 rootRunId 全局唯一;公司根那侧要多一层 `_companies/` 是另一回事
 * (公司 id 不是全局唯一坐标,扁平放会和 run 目录撞名),别把两种形状抄串。
 *
 * GRAPH_RUNS_DIR **每次调用时读**,不在模块加载时快照 —— 教训出处见
 * `company-paths.js` 文件头:镜像时代各模块自己快照,加载顺序不同就会算出不同的根,
 * 测试「先 require 再设 env」时尤其明显。此前这里正是模块加载时快照的那一份,
 * 而且快照完一次都没用上,注释却写着「测试隔离在用」——注释撒谎比没有注释更能骗人。
 */
function resolveRunDir(companyId, rootRunId) {
  const runsRoot = process.env.GRAPH_RUNS_DIR || null;
  return runsRoot
    ? path.join(runsRoot, String(rootRunId))
    : 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/,拒绝执行',
};

/**
 * 35 号文档提案 D:evidence_hint 里点名"打开浏览器/视口/开发者工具/截图"这类维度,
 * 光有一个分数不够——34 号文档真实撞见过 validator 给这类维度打满分、却没有任何
 * 迹象证明它真的做过对应检查。这里只做**关键词检测 + 证据字段存在性检查**,
 * 不校验证据内容真假(那需要重新起一次浏览器去验证,超出「引擎判分时能做什么」
 * 的范围,留给下次;见 35 号文档 §二 提案 D 的范围说明)。
 */
const EVIDENCE_REQUIRED_RE = /浏览器|viewport|视口|开发者工具|devtools|console|network|截图|screenshot/i;
function requiresEvidence(dimension) {
  return EVIDENCE_REQUIRED_RE.test(String((dimension && dimension.evidence_hint) || ''));
}

/**
 * `--target` 通常是 `attempt-<n>.scores.json`(只有分数,见 benchmark-validator/agent.md
 * Step 3),证据写在**同目录的姊妹文件** `attempt-<n>.json`(判词文件,Step 5)的
 * `dimensions[].evidence` 里——这是已有的、agent.md 早就在教的约定,不是本次新加的格式。
 * 这里只是把两份文件的证据接起来给 runBenchmark 用,不要求 agent 多写一份。
 */
function readSiblingEvidence(targetAbs) {
  if (!targetAbs || !targetAbs.endsWith('.scores.json')) return {};
  const siblingPath = `${targetAbs.slice(0, -'.scores.json'.length)}.json`;
  try {
    const doc = JSON.parse(fs.readFileSync(siblingPath, 'utf8'));
    const dims = Array.isArray(doc.dimensions) ? doc.dimensions : [];
    const out = {};
    for (const d of dims) {
      if (d && typeof d.name === 'string' && typeof d.evidence === 'string' && d.evidence.trim()) {
        out[d.name] = d.evidence;
      }
    }
    return out;
  } catch {
    return {}; // 判词文件还没写 / 不是合法 JSON:不该拖垮打分本身,靠下面的证据缺失走正常失败分支
  }
}

/**
 * benchmark:被测产物提供各维度分数,执行器只做加权与阈值比较(纯声明式,不起 LLM)。
 * 被测产物形态:{"scores": {"<dimension>": 87, ...}} 或直接 {"<dimension>": 87, ...}。
 * `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑——证据来源两处都认:
 * `--target` 文件里自带的 `evidence.<dim>`(谁都可以这么写),或者同目录姊妹判词文件
 * `attempt-<n>.json` 里 `dimensions[].evidence`(`benchmark-validator` 已有的既定写法,
 * 见上面 `readSiblingEvidence`);`--target` 里的显式声明优先。
 */
function runBenchmark(sec, target, targetAbs) {
  // 短路分支也必须进 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 explicitEvidence = (target.json && typeof target.json.evidence === 'object' && target.json.evidence) || {};
  const evidence = { ...readSiblingEvidence(targetAbs), ...explicitEvidence };
  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 needsEvidence = requiresEvidence(d);
    const hasEvidence = !needsEvidence
      || (typeof evidence[d.name] === 'string' && evidence[d.name].trim() !== '');
    const passed = has && hasEvidence && (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,
      evidence_required: needsEvidence || undefined,
      reason: !has ? 'missing score in target' : (!hasEvidence ? 'evidence_required' : undefined),
    });
  }

  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 === 'evidence_required'
        ? `evidence_hint 要求真实检查(浏览器/视口/开发者工具类),但被测产物没有在 evidence.${d.name} 留下证据——只给了一个分数`
        : (d.reason || 'dimension score below pass_threshold'),
    })),
  };
}

/**
 * test_data:被测产物提供各 case 的实际输出,执行器逐 case 比对(纯声明式,不起 LLM)。
 * 被测产物形态:{"results": {"<caseId>": <output>}} 或 {"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<object>} 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, targetAbs);
  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 <D> --section <benchmark|test_data|test_script>
           (--file <X.json> | --ref <artifacts 下的相对路径>)
           [--goal-id <G>] [--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 <D>
           只校验契约本身(空契约 / 权重不足 1 / runner 越界 / 路径逃逸都在这里被挡)。

  run      --run-dir <D> --target <被测产物路径>
           [--run-id <R>] [--node-run-id <N>] [--attempt <n>] [--json]
           消费侧:读契约 → 执行 → 落盘判决。缺省的 run-dir / node-run-id / attempt 会从 target 路径推。

  list     --run-dir <D> [--node-run-id <N>] [--only-failed] [--limit <n>]
  show     --run-dir <D> --verdict-id <V>

退出码: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,
};