@stalfh233/omc-cli 0.6.24 → 0.6.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -238,8 +238,10 @@ export const CAPABILITY_CUES = Object.freeze({
238
238
  "不早于", "不能早于", "不晚于", "不能晚于", "早于今天", "晚于今天", "日期校验", "提交校验", "校验不通过", "必填校验", "正则校验", "重复校验", "长度校验"],
239
239
  // NOTE: bare "自动" was a cue (round 10 bug: "自动识别"/"自动发邮件" matched
240
240
  // businessrule and routed an UNSUPPORTED ask onto the write ladder). A business
241
- // rule is named by 业务规则/触发器/规则图, not by "automatic".
242
- businessrule: ["业务规则", "触发器", "规则图", "校验", "事件", "按钮", "定时任务", "定时触发", "定时器", "每天", "每日", "周期执行", "调度", "SCHEDULER", "TIMING_TASK"],
241
+ // rule is named by 业务规则/触发器/规则图, not by "automatic". r44fc(2026-11-08)
242
+ // 重犯过一次(round10 测试当场抓住)——复合词(累加/联动/审计/规则触发/自动累加)才是
243
+ // 规则面线索,裸「自动」永远不是。
244
+ businessrule: ["业务规则", "触发器", "规则图", "校验", "事件", "按钮", "定时任务", "定时触发", "定时器", "每天", "每日", "周期执行", "调度", "SCHEDULER", "TIMING_TASK", "累加", "联动", "审计", "规则触发", "自动累加", "自动回写"],
243
245
  "rule-lifecycle": ["启用规则", "停用规则", "删除规则", "规则启停", "触发规则", "执行规则", "跑规则", "按钮规则", "规则验收"],
244
246
  // r28h F1/F7: narrow org cues so "管理组织:部门/角色/角色组/用户/权限范围"
245
247
  // hits workflow (where org.* lives) instead of field→workflow. Keep them
@@ -347,6 +349,7 @@ const CAPABILITY_TOPICS = Object.freeze({
347
349
  "rule-lifecycle": ["app-scope", "omc-business-configuration", "omc-refusals"],
348
350
  workflow: ["omc-business-configuration", "omc-refusals"],
349
351
  onlinejs: ["omc-business-configuration", "omc-write-hazards"],
352
+ programming: ["omc-backend-online-coding", "omc-business-configuration", "omc-write-hazards"],
350
353
  bizservice: ["omc-business-configuration", "omc-write-hazards"],
351
354
  report: ["omc-business-configuration", "omc-write-hazards"],
352
355
  data: ["omc-business-configuration", "omc-refusals"],
package/dist/cli.js CHANGED
@@ -2225,7 +2225,7 @@ async function main() {
2225
2225
  commandExitCode = CLI_EXIT_CODES.ok;
2226
2226
  });
2227
2227
  command.command("doctor").option("--environment <environment>").option("--env <environment>").option("--service <serviceCode>", "可选:体检单个业务集成服务的鉴权参数形状(掩码/占位/全 0 检出,WARN 非致命)").action(async (options) => { commandExitCode = await cmdDoctor(new ConfigStore(defaultPaths()), options.environment ?? options.env, options.service); });
2228
- command.command("call <tool>").option("--env <environment>", "环境名;项目白名单按当前 cwd 解析,须在含 .omc-env 的目录内运行").option("--environment <environment>").option("--args <json>").option("--args-file <path>", "从文件读参数 JSON。**必须位于配置根内**(数组批量请改用 --args-list;跨目录/系统临时文件会被拒);免落盘用 `--args -`(stdin)").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象,最多 200 条);超过上限请分批,或用 omc script 读文件。逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").option("--keys", "与 artifact get 的 --keys 同义(= --extract-keys):列出本次结果 artifact 的顶层键与体积").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>", "最终 stdout 信封预算;默认 65536(投影/--jq 之后再判);读面 diet(~2048) 独立外置 detailArtifactId,抬高本参数不会把已 diet 的大表重新内联;artifact get 内联上限 8192").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
2228
+ command.command("call <tool>").option("--env <environment>", "环境名;项目白名单按当前 cwd 解析,须在含 .omc-env 的目录内运行").option("--environment <environment>").option("--args <json>").option("--args-file <path>", "从文件读参数 JSON。**必须位于配置根内**(数组批量请改用 --args-list;跨目录/系统临时文件会被拒);免落盘用 `--args -`(stdin)").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象,最多 200 条);超过上限请分批,或用 omc script 读文件。逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").option("--keys", "与 artifact get 的 --keys 同义(= --extract-keys):列出本次结果 artifact 的顶层键与体积").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>", "最终 stdout 信封预算;默认 65536(投影/--jq 之后再判);读面 diet(~2048) 独立外置 detailArtifactId,抬高本参数不会把已 diet 的大表重新内联;artifact get 内联上限 8192").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>", "仅 `omc call` / `omc intent apply` 支持;写锁冲突时有界排队(0=立刻拒绝)").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
2229
2229
  const rest = [tool, ...(options.env ? ["--env", options.env] : []), ...(options.environment ? ["--environment", options.environment] : []), ...(options.args !== undefined ? ["--args", options.args] : []), ...(options.argsFile !== undefined ? ["--args-file", options.argsFile] : []), ...(options.argsList !== undefined ? ["--args-list", options.argsList] : []), ...(options.preflight ? ["--preflight"] : []), ...(Array.isArray(options.extract) ? options.extract.flatMap((path) => ["--extract", path]) : []), ...(Array.isArray(options.jq) ? options.jq.flatMap((path) => path === true ? ["--jq"] : ["--jq", path]) : []), ...(options.extractKeys ? ["--extract-keys"] : []),
2230
2230
  ...(options.keys ? ["--keys"] : []), ...(options.maxOutputBytes !== undefined ? ["--max-output-bytes", options.maxOutputBytes] : []), ...(options.requestId ? ["--request-id", options.requestId] : []), ...(options.timeoutMs !== undefined ? ["--timeout-ms", options.timeoutMs] : []), ...(options.wait !== undefined ? ["--wait", options.wait] : []), ...(options.dryRun ? ["--dry-run"] : [])];
2231
2231
  commandExitCode = await cmdCall(new ConfigStore(defaultPaths()), rest);
@@ -2254,7 +2254,7 @@ async function main() {
2254
2254
  .option("--env <environment>")
2255
2255
  .option("--environment <environment>")
2256
2256
  .option("--apply")
2257
- .addHelpText("after", "\n一条命令按依赖倒序清掉一个模型:列表视图 → 业务规则 → 计算规则 → 流程实例 → 流程 → 模型(字段/表单随模型级联)。默认 dry-run。\n业务数据:--purge-instances 删流程实例;普通业务行(无流程)不会自动带走——先 data.delete 清(sweep-all-readback 配方),再 purge,否则留孤儿数据。\n")
2257
+ .addHelpText("after", "\n一条命令按依赖倒序清掉一个模型:列表视图 → 业务规则 → 计算规则 → 流程实例 → 流程 → 模型(字段/表单随模型级联,并级联删除流程模板 <code>_wf 及其发起权限绑定)。默认 dry-run。\n业务数据:--purge-instances 删流程实例;普通业务行(无流程)不会自动带走——先 data.delete 清(sweep-all-readback 配方),再 purge,否则留孤儿数据。\n")
2258
2258
  .action(async (options) => {
2259
2259
  const rest = ["model-purge",
2260
2260
  ...(options.code ? ["--code", options.code] : []),
@@ -2371,6 +2371,7 @@ async function main() {
2371
2371
  .option("--name <name>", "模型名")
2372
2372
  .option("--fields <json>", "JSON 数组,每条是一个 field.create 参数;子表加 subFields")
2373
2373
  .option("--fields-json <json>")
2374
+ .option("--fields-file <path>", "从配置根内文件读字段 JSON 数组;与 --fields 互斥")
2374
2375
  .option("--folder <folderId>", "建在哪个应用目录(省略=默认目录)")
2375
2376
  .option("--app <applicationCode>")
2376
2377
  .option("--env <environment>")
@@ -2396,6 +2397,7 @@ async function main() {
2396
2397
  ...(options.name ? ["--name", options.name] : []),
2397
2398
  ...(options.fields !== undefined ? ["--fields", options.fields] : []),
2398
2399
  ...(options.fieldsJson !== undefined ? ["--fields-json", options.fieldsJson] : []),
2400
+ ...(options.fieldsFile !== undefined ? ["--fields-file", options.fieldsFile] : []),
2399
2401
  ...(options.folder ? ["--folder", options.folder] : []),
2400
2402
  ...(options.app ? ["--app", options.app] : []),
2401
2403
  ...(options.env ? ["--env", options.env] : []),
@@ -2416,6 +2418,7 @@ async function main() {
2416
2418
  .option("--code <schemaCode>")
2417
2419
  .option("--fields <json>", "JSON 数组,每条是一个 field.create 参数(choices/字典 options 原样透传)")
2418
2420
  .option("--fields-json <json>")
2421
+ .option("--fields-file <path>", "从配置根内文件读字段 JSON 数组;与 --fields 互斥")
2419
2422
  .option("--env <environment>")
2420
2423
  .option("--environment <environment>")
2421
2424
  .option("--no-form", "跳过表单重建")
@@ -2434,6 +2437,7 @@ async function main() {
2434
2437
  ...(options.code ? ["--code", options.code] : []),
2435
2438
  ...(options.fields !== undefined ? ["--fields", options.fields] : []),
2436
2439
  ...(options.fieldsJson !== undefined ? ["--fields-json", options.fieldsJson] : []),
2440
+ ...(options.fieldsFile !== undefined ? ["--fields-file", options.fieldsFile] : []),
2437
2441
  ...(options.env ? ["--env", options.env] : []),
2438
2442
  ...(options.environment ? ["--environment", options.environment] : []),
2439
2443
  ...(options.form === false ? ["--no-form"] : []),
@@ -2530,7 +2534,7 @@ async function main() {
2530
2534
  pureKeys: (options.keys === true || bareJq) && options.summary !== true, ...(typeof options.keys === "string" ? { keys: options.keys } : {}), full: options.full === true }));
2531
2535
  });
2532
2536
  command.command("bench").option("--live-sample").option("--env <environment>").action(async (options) => { commandExitCode = await cmdBench([...(options.liveSample ? ["--live-sample"] : []), ...(options.env ? ["--env", options.env] : [])]); });
2533
- command.command("residue <prefix>").description("跨面前缀残留扫描(model 全租户/folder/custompage;默认 deep 再查命中模型的 rule/datarule/onlinejs)——收尾「删净」一次证明,clean:true 即零残留。清理入口:omc model-purge --code <schemaCode>(按依赖倒序,默认 dry-run)。只读零写入;report 面暂停维护未扫描").option("--env <environment>", "环境名(默认按 cwd 的 .omc-env 解析)").option("--apps <codes>", "all(默认,全租户)或逗号分隔应用码").option("--no-deep", "只扫 model/folder/custompage,不深查命中模型").option("--max-output-bytes <bytes>").action(async (prefix, options) => { commandExitCode = await withTransport(options, request, (transport) => cmdResidue(prefix, { env: options.env, apps: options.apps, ...(options.deep === false ? { noDeep: true } : {}) }, transport)); });
2537
+ command.command("residue <prefix>").description("跨面前缀残留扫描(model 全租户/folder/custompage;默认 deep 再查命中模型的 rule/datarule/onlinejs)——收尾「删净」一次证明,clean:true 即零残留。清理入口:omc model-purge --code <schemaCode>(按依赖倒序,默认 dry-run)。只读零写入;report 面暂停维护未扫描").option("--env <environment>", "环境名(默认按 cwd 的 .omc-env 解析)").option("--apps <codes>", "all(默认,全租户)或逗号分隔应用码").option("--no-deep", "只扫 model/folder/custompage,不深查命中模型").option("--all-faces", "列出全部应用×面(含 count:0;默认只回非零面+byFace/userResidues 紧凑形)").option("--max-output-bytes <bytes>").action(async (prefix, options) => { commandExitCode = await withTransport(options, request, (transport) => cmdResidue(prefix, { env: options.env, apps: options.apps, ...(options.deep === false ? { noDeep: true } : {}), ...(options.allFaces ? { allFaces: true } : {}) }, transport)); });
2534
2538
  const jqOption = () => new Option("--jq [path]", "路径投影(与 call --jq 同 DSL;可逗号多路径;裸 --jq 列顶层键)").argParser((value, previous = []) => [...previous, value]).default([]);
2535
2539
  command.command("commands").option("--json").option("--compact").option("--filter <keyword>").option("--lane <id>").option("--max-output-bytes <bytes>").addOption(jqOption()).action(async (options) => { commandExitCode = await withTransport(options, request, (transport) => cmdDiscovery("commands", transport, undefined, { json: options.json === true, compact: options.compact === true, ...(options.filter ? { filter: options.filter } : {}), ...(options.lane ? { lane: options.lane } : {}), ...(options.jq !== undefined ? { jq: options.jq } : {}) })); });
2536
2540
  command.command("describe <tool>").option("--full").option("--cache-ok", "允许 --full 在同会话重复读时短路为 artifact 指针(默认每次回全文)").option("--schema-only").option("--param <name>").option("--example [id]").option("--max-output-bytes <bytes>").addOption(jqOption()).action(async (tool, options) => {
package/dist/field-add.js CHANGED
@@ -17,6 +17,9 @@ import { argvValue } from "./args.js";
17
17
  import { runToolCall } from "./call.js";
18
18
  import { CLI_EXIT_CODES, classifyResult, envelope, refusalText, requestId } from "./cli-contract.js";
19
19
  import { writeEnvelope } from "./cli-output.js";
20
+ import { assertSafeInputPath } from "./artifacts.js";
21
+ import { readFileSync } from "node:fs";
22
+ import { resolve } from "node:path";
20
23
  /**
21
24
  * 字典简写展开(r30/r36 实证:字典字段要先 names 查 id、records 取记录、
22
25
  * 再拼 optionsSet.dictionary 三段 4–5 调;`dictCode` 把它折进 field-add 一步):
@@ -108,7 +111,9 @@ export async function cmdFieldAdd(args, contextForTest) {
108
111
  const request = requestId();
109
112
  const flag = (name) => argvValue(args, name);
110
113
  const schemaCode = flag("--schema") ?? flag("--schema-code") ?? flag("--code");
111
- const fieldsJson = flag("--fields") ?? flag("--fields-json");
114
+ const fieldsFile = flag("--fields-file");
115
+ const fieldsFlag = flag("--fields");
116
+ const fieldsJson = fieldsFlag === "-" ? readFileSync(0, "utf8") : fieldsFlag ?? flag("--fields-json");
112
117
  const environment = flag("--env") ?? flag("--environment");
113
118
  const apply = args.includes("--apply");
114
119
  const publishForm = !args.includes("--no-form");
@@ -117,7 +122,7 @@ export async function cmdFieldAdd(args, contextForTest) {
117
122
  const conditionsJson = flag("--conditions") ?? flag("--conditions-json");
118
123
  const demoRowsJson = flag("--demo-rows") ?? flag("--demo-rows-json");
119
124
  const usage = [
120
- "用法:omc field-add --schema <模型code> --fields '<JSON 数组>' [--apply]",
125
+ "用法:omc field-add --schema <模型code> --fields '<JSON 数组>' [--fields-file <路径>|--fields -] [--apply]",
121
126
  " --fields 每项是一个 field.create 的参数:{\"code\",\"name\",\"propertyType\",...}(choices/字典 options 原样透传);",
122
127
  " 字典简写:{\"dictCode\":\"<字典code>\"} 代替手拼 options.optionsSet(内部解析字典 id+启用记录,recipe 同形状)。",
123
128
  " 默认 dry-run;--apply 依次执行 建字段 → 发布字段 → 重建表单 → 列表追加列。",
@@ -125,6 +130,7 @@ export async function cmdFieldAdd(args, contextForTest) {
125
130
  " --no-form 跳过表单重建;--no-list 跳过列表加列(默认两者都做)。",
126
131
  " --conditions '<JSON 数组>' 列表固定过滤条件(listview.configure 契约;**整体替换**既有固定条件——要保留旧的须一并带上;不传则不动);需 --list(默认开)。",
127
132
  " --demo-rows '<JSON 数组>' 演示数据行(每项一份 formData,逐行 data.submit);非幂等——重复执行会重复插行。",
133
+ " --fields-file 从配置根内文件读 JSON 数组;--fields - 从 stdin 读 JSON 数组。",
128
134
  ].join("\n");
129
135
  const invalid = (message) => {
130
136
  writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: `${message}\n${usage}`, retryable: false }));
@@ -132,11 +138,23 @@ export async function cmdFieldAdd(args, contextForTest) {
132
138
  };
133
139
  if (!schemaCode)
134
140
  return invalid("--schema 必填(存量模型 code)");
135
- if (fieldsJson === undefined)
136
- return invalid("--fields 必填(JSON 数组)");
141
+ if (fieldsFile !== undefined && (fieldsFlag !== undefined || flag("--fields-json") !== undefined))
142
+ return invalid("--fields-file 不能与 --fields/--fields-json 同时使用");
143
+ let inputJson = fieldsJson;
144
+ if (fieldsFile !== undefined) {
145
+ try {
146
+ const root = resolve(process.env.OMC_CONFIG_HOME ?? process.cwd());
147
+ inputJson = readFileSync(assertSafeInputPath(root, fieldsFile, "--fields-file"), "utf8");
148
+ }
149
+ catch (error) {
150
+ return invalid(`--fields-file 读取失败:${error instanceof Error ? error.message : String(error)}`);
151
+ }
152
+ }
153
+ if (inputJson === undefined)
154
+ return invalid("--fields 必填(JSON 数组;或使用 --fields-file/--fields -)");
137
155
  let fields;
138
156
  try {
139
- const parsed = JSON.parse(fieldsJson);
157
+ const parsed = JSON.parse(inputJson);
140
158
  if (!Array.isArray(parsed) || parsed.length === 0)
141
159
  throw new Error("--fields 必须是非空 JSON 数组");
142
160
  fields = parsed;
@@ -625,6 +625,8 @@ export function normalizeMappingsOption(options) {
625
625
  const target = record.target;
626
626
  if (typeof source !== "string" || source.trim() === "" || typeof target !== "string" || target.trim() === "")
627
627
  mappingsError("每项的 source/target 必须是非空字符串");
628
+ if (source.includes(".") || target.includes("."))
629
+ mappingsError("source/target 应为目标模型字段 code,不带模型前缀(不要使用 模型.字段)");
628
630
  return { source: source.trim(), target: target.trim() };
629
631
  });
630
632
  normalized = JSON.stringify(rows);
@@ -644,6 +646,8 @@ export function normalizeMappingsOption(options) {
644
646
  }
645
647
  if (!Array.isArray(parsed) || parsed.some((row) => row === null || typeof row !== "object" || Array.isArray(row) || typeof row.source !== "string" || typeof row.target !== "string" || row.source === "" || row.target === ""))
646
648
  mappingsError("解析结果必须是 [{source,target}] 形状(source/target 非空字符串)");
649
+ if (parsed.some((row) => String(row.source).includes(".") || String(row.target).includes(".")))
650
+ mappingsError("source/target 应为目标模型字段 code,不带模型前缀(不要使用 模型.字段)");
647
651
  normalized = JSON.stringify(parsed.map(({ source, target }) => ({ source: source.trim(), target: target.trim() })));
648
652
  }
649
653
  }
@@ -934,14 +938,14 @@ export function assertSelectionChoicesAllowed(args) {
934
938
  }
935
939
  function refuseFieldOptions(reason, message) {
936
940
  const citation = {
937
- code: "capability-out-of-coverage",
941
+ code: "field-options-shape",
938
942
  lane: "field-families",
939
943
  capability: "field family full chain",
940
944
  status: "blocked",
941
945
  reason,
942
946
  message,
943
947
  };
944
- throw new ToolRefusalError({ status: "refused", blocker: "capability-out-of-coverage", citation });
948
+ throw new ToolRefusalError({ status: "refused", blocker: "field-options-shape", citation });
945
949
  }
946
950
  /**
947
951
  * Aggregation query conditions are deserialized by Jackson into Java enums that
@@ -1338,6 +1338,7 @@ export async function executeFormDraftWrite(session, args) {
1338
1338
  attributes = undefined;
1339
1339
  }
1340
1340
  let rebuildPreservedControls = [];
1341
+ let controlsUpdated = 0;
1341
1342
  if (typeof view !== "string" || typeof attributes !== "string") {
1342
1343
  // Ticket 48: an explicit fieldCodes selection picks (and orders) the
1343
1344
  // scaffolded business fields; omitted scaffolds every published field.
@@ -1364,6 +1365,7 @@ export async function executeFormDraftWrite(session, args) {
1364
1365
  attributes = JSON.stringify(scaffold.attributes);
1365
1366
  }
1366
1367
  if (args.draftAttributesJson === undefined && typeof attributes === "string") {
1368
+ const beforeDerived = attributes;
1367
1369
  attributes = propagateRelationMappings(attributes, publishedFields);
1368
1370
  // Ticket 69: repair/densify org-selection controls on the existing draft.
1369
1371
  attributes = normalizeOrgSelectorControls(attributes, publishedFields);
@@ -1374,6 +1376,16 @@ export async function executeFormDraftWrite(session, args) {
1374
1376
  // control even when the form already existed — a non-rebuild draft keeps
1375
1377
  // controls verbatim, so a later field.update never propagated (bench20).
1376
1378
  attributes = syncFieldDerivedControlOptions(attributes, publishedFields);
1379
+ if (args.refreshFromFields === true && attributes !== beforeDerived) {
1380
+ try {
1381
+ const before = JSON.parse(beforeDerived);
1382
+ const after = JSON.parse(attributes);
1383
+ controlsUpdated = Object.keys(after).filter((code) => JSON.stringify(before[code]) !== JSON.stringify(after[code])).length;
1384
+ }
1385
+ catch {
1386
+ controlsUpdated = 0;
1387
+ }
1388
+ }
1377
1389
  }
1378
1390
  // Fail-closed: a rebuild that would drop a designer formula must never reach
1379
1391
  // the wire. The merge above normally carries them, but it cannot when the
@@ -1428,6 +1440,7 @@ export async function executeFormDraftWrite(session, args) {
1428
1440
  }
1429
1441
  : {}),
1430
1442
  ...(args.rebuild === true && previousAttributes ? { rebuildPreservedControls } : {}),
1443
+ ...(args.refreshFromFields === true ? { refreshFromFields: true, controlsUpdated } : {}),
1431
1444
  // r36(2026-11-01):rebuild 后草稿与重建前逐字节一致时明示——rebuild 的
1432
1445
  // 语义就是"按已发布字段重搭",一致通常意味着预期的新控件没进来(字段未
1433
1446
  // 发布/写错槽位),静默 no-op 会让"新增字段后刷新表单"看起来成功实则没变。
@@ -30,6 +30,9 @@ export const FORMULA_FUNCTION_ALIASES = Object.freeze({
30
30
  STRCAT: "STRING",
31
31
  JOIN: "STRING",
32
32
  });
33
+ export const FORMULA_LINT_CODES = new Set([
34
+ "formula-function-unknown", "formula-function-alias", "formula-text-plus-concat",
35
+ ]);
33
36
  const TEXT_PLUS_CONCAT = /(\{[^{}]+\}|[A-Za-z_][A-Za-z0-9_]*\s*\([^()]*\))\s*\+\s*["']|["']\s*\+\s*(\{[^{}]+\}|[A-Za-z_][A-Za-z0-9_]*\s*\()/;
34
37
  /**
35
38
  * Lint one formula string. `where` names the carrier in the caller's own terms
@@ -973,7 +973,7 @@ export function goalRoute(registry, goal) {
973
973
  goal,
974
974
  confident: true,
975
975
  mode: whole ? "whole-package" : mode,
976
- match: { kind: readish ? "read-ladder" : "ladder", ref: ordered.join("→"), capabilities: ordered },
976
+ match: { kind: readish ? "read-ladder" : "ladder", ref: `${ordered.join("→")}${ordered.includes("businessrule") && /累加|联动|审计|规则触发|自动累加|自动回写/.test(goal) ? "(规则面不在本阶梯)" : ""}`, capabilities: ordered },
977
977
  route: {
978
978
  ...(whole ? { primary: "intent", atomicSupplement } : { ladder }),
979
979
  // r28e F2(真机复现 2026-11-05):`next "建表单布局、在线JS、表单按钮"`
@@ -1059,7 +1059,7 @@ export function goalRoute(registry, goal) {
1059
1059
  mode: whole ? "whole-package" : mode,
1060
1060
  match: {
1061
1061
  kind: readish ? "read-capability" : recipe ? "recipe" : "capability",
1062
- ref: recipe ? recipe.recipe : capabilityId,
1062
+ ref: recipe ? recipe.recipe : `${capabilityId}${capabilityId === "businessrule" && /累加|联动|审计|规则触发|自动累加|自动回写/.test(goal) ? "(规则面不在本阶梯)" : ""}`,
1063
1063
  title: firstSentence(recipe ? recipe.title : route.title),
1064
1064
  capability: capabilityId,
1065
1065
  ...(recipe ? { tool: recipe.tool, recipe: recipe.recipe } : {}),
@@ -72,8 +72,10 @@ const RULE_SAVE_DESCRIPTION = describeTool("保存业务规则(upsert):`ru
72
72
  "**条件执行**:① 单分支→动作节点自身 `filterCondition`(UPDATE/DELETE/ASSIGN 的 `mainConditions`:身份匹配 DYNAMIC + 固定条件 FIXED,同叶组 AND)。② 多路分支→`BRANCH` 节点:出口叶(DataFilterCondition)键为 `{currentSchemaDataItem(左值),ruleDataConditionType(操作符),ruleValueType,value}`,出路由 `routeCondition:true` 走条件、`false` 兜底 else。**别照抄其它条件组的 targetSchemaDataItem+ruleConditionType——未知键 save+publish 过但运行时 50000(lint fail closed `rule-graph-branch-condition-keys`)**。按数据走流程流转用 `ruleConditionGroup`。",
73
73
  "**生命周期铁律(2026-09-11 live 取证)**:update 只写 DRAFT 工作行,get/list 读 PUBLISH 面因此看不到变化——必须再 `rule.enable`(publish 出新版本 + 翻 DRAFT 启用开关)才会生效;数据事件分发:DRAFT 保存不触发,submit(DRAFT→COMPLETED)触发 Create。保存 ≠ 设计器可见:readback 的 `published:false` + nextAction 标明尚未发布;传 `publish:true` 可在同一次调用内发布新版本(仅发布,启用仍走 `rule.enable`)。",
74
74
  "**BizMethod 绑定(ticket 35/96)**:BIZ_ACTION / GET_LIST(BIZ_SERVICE) 的 methodMapping 须逐键对 8.6.26 DTO(codeType int 0=CONST/1=PROPERTY…;非语义键原样透传);写前 wire 校验服务+方法真实存在(get_by_code + list_by_service_code),悬空即 refused `rule-graph-bizmethod-unresolved`——平台只拦服务不拦方法,绝不用注册壳冒充已发布 BizMethod;RESTFUL/DATA_SQL 方法必须带适配器必需 configJson 键,只有名字/参数面的注册壳 refused `rule-graph-bizmethod-registration-shell`;映射 `required` 以方法参数面为权威镜像(省略→按面填入,面缺席→保持缺席,显式矛盾→本地拒绝零写入)。",
75
+ "**EXPRESSION 值形状**:`ruleValueType:EXPRESSION` 时 `value` 必须是 {\"formula\":\"{字段A}+{字段B}\"}(大括号裸 code);门户设计器的 [code] 方括号式在这里不认。",
75
76
  "**幻影 schema 门(ticket 96 live 事故)**:rule.save 写前先以 bizmodels/get_all 探测模型存在(缺失 → refused `rule-schema-unresolved`,零规则面 wire 调用)——平台的 list/save 面会对任意 schemaCode 自动补建 7 条预置规则且 API 删除不可达(list 即重灌),幻影 schema 必须在写前拒绝。",
76
77
  "**输出回写排序(ticket 36 live 铁证)**:BIZ_ACTION 的 outputMappings 由 SYSTEM_CREATE/SYSTEM_UPDATE 持久化节点消费,动作节点必须排在系统节点之前(`Start→BizAction→SYSTEM_CREATE→End`);工具默认把系统节点补在 START 之后,有回写动作时须显式提供系统节点并置于动作之后,否则合并结果不落库(echoResult 恒 null)。",
78
+ "**跨模型 UPDATE 三段语义(1.1.25 反编译定案)**:`inputParam`=输入源(触发行或 QUERY 上游输出,`currentSchemaDataItem` 属此侧)→ `filterCondition` 按 `targetSchemaCode` 查目标行 → `dataActions` 把输入源值写入目标行。QUERY 目标行不自动成为输入源;child fan-out 保持 fail-closed(用显式中间字段/映射/二开)。",
77
79
  ]);
78
80
  export const BUSINESSRULE_LANE_CONTRACT = Object.freeze({
79
81
  lane: LANES.businessrule,
@@ -479,6 +479,7 @@ export function registerBusinessRuleLane(tools) {
479
479
  name: z.string().min(1),
480
480
  nodeGraph: ruleNodeGraphArg,
481
481
  publish: z.boolean().optional().describe("also publish the new version in this call (businessrule/publish) so the designer sees it; enabling stays with rule.enable — default false keeps the legacy draft-only save"),
482
+ allowAdvisory: z.boolean().optional().describe("调查逃生口;默认 false"),
482
483
  customTrigger: z.boolean().optional().describe("默认开:自定义 ruleCode + bizRuleType 0(非平台触发码 Create/Update/Delete/GetList/Load/Available/Cancel)即按钮/流程规则,工具在 START 后合成平台标准 CUSTOM_NODE「自定义按钮/流程触发」系统触发节点(nodeCode=ruleCode,live 布局 x558/y233);调用方已写 CUSTOM_NODE 则原样保留;显式 false 退回"),
483
484
  },
484
485
  execute: async (session, args) => executeRuleSaveWrite(session, args),
@@ -481,6 +481,11 @@ export function dataRuleFormulaWarnings(dataRuleType, options) {
481
481
  return [];
482
482
  return lintFormulaText(formula, "options.shortTextStitch.formula");
483
483
  }
484
+ function assertDataRuleFormulaLint(dataRuleType, options, allowAdvisory) {
485
+ const findings = dataRuleFormulaWarnings(dataRuleType, options);
486
+ if (findings.length > 0 && allowAdvisory !== true)
487
+ throw new ToolRefusalError({ status: "refused", blocker: "formula-lint-failed", validation: findings, nextCommand: "修正公式函数/字符串拼接;调查用途显式传 allowAdvisory:true", message: "公式写前校验拒绝:未知函数、平台别名或字符串 + 拼接。文本合并请使用 STRING()。" });
488
+ }
484
489
  /** Extract the expression of a type-2 CALCULATE rule (mirrors calcExpressionShapeError's carrier choice). */
485
490
  function type2FormulaText(options) {
486
491
  const raw = typeof options.shortTextStitch === "string" ? options.shortTextStitch : typeof options.value === "string" ? options.value : undefined;
@@ -745,14 +750,17 @@ const FORM_CONDITION_RULE_TYPES = Object.freeze({
745
750
  export async function syncFormConditionDataRules(session, schemaCode, fieldCode, prevOptions, nextOptions) {
746
751
  const result = { created: [], updated: [], deleted: [], skipped: [] };
747
752
  const text = (options, key) => (typeof options[key] === "string" ? options[key].trim() : "");
748
- const changedKeys = Object.keys(FORM_CONDITION_RULE_TYPES).filter((key) => text(prevOptions, key) !== text(nextOptions, key));
749
- if (changedKeys.length === 0)
750
- return result;
751
753
  const existing = await listDataRules(session, schemaCode);
752
754
  // The designer's rule rows live on the PUBLISHED face (like datarule.create's
753
755
  // own guard); a draft-only field cannot carry a designer-visible row yet.
754
756
  const publishedCodes = await readPublishedFieldCodesOrNull(session, schemaCode);
755
757
  const published = publishedCodes === null ? true : publishedCodes.has(fieldCode);
758
+ const changedKeys = Object.keys(FORM_CONDITION_RULE_TYPES).filter((key) => {
759
+ const changed = text(prevOptions, key) !== text(nextOptions, key);
760
+ const desired = text(nextOptions, key);
761
+ const present = existing.some((row) => Number(row.dataRuleType) === FORM_CONDITION_RULE_TYPES[key] && storedTargetCodes(row).includes(fieldCode));
762
+ return changed || (desired.length > 0 && !present);
763
+ });
756
764
  for (const key of changedKeys) {
757
765
  const dataRuleType = FORM_CONDITION_RULE_TYPES[key];
758
766
  const next = text(nextOptions, key);
@@ -869,9 +877,11 @@ export function registerDataRuleLane(tools) {
869
877
  childRule: z.boolean().optional().describe("true=子表列规则,propertyCode 为子表列 code;主表字段忽略"),
870
878
  name: z.string().optional(),
871
879
  sortKey: z.number().int().optional(),
880
+ allowAdvisory: z.boolean().optional().describe("调查逃生口;默认 false"),
872
881
  },
873
882
  execute: async (session, args) => {
874
883
  assertDataRuleOptions(args.dataRuleType, args.options, args.checkType);
884
+ assertDataRuleFormulaLint(args.dataRuleType, args.options, args.allowAdvisory);
875
885
  assertDataRuleCheckType(args.dataRuleType, args.checkType);
876
886
  assertDataRuleTarget(args);
877
887
  const codes = dataRuleTargetCodes(args);
@@ -954,6 +964,7 @@ export function registerDataRuleLane(tools) {
954
964
  propertyCodes: propertyCodesArg,
955
965
  dataRuleType: dataRuleTypeArg,
956
966
  options: dataRuleOptionsArg,
967
+ allowAdvisory: z.boolean().optional().describe("调查逃生口;默认 false"),
957
968
  checkType: checkTypeArg,
958
969
  childRule: z.boolean().optional().describe("true=子表列规则,propertyCode 为子表列 code;主表字段忽略"),
959
970
  name: z.string().optional(),
@@ -961,6 +972,7 @@ export function registerDataRuleLane(tools) {
961
972
  },
962
973
  execute: async (session, args) => {
963
974
  assertDataRuleOptions(args.dataRuleType, args.options, args.checkType);
975
+ assertDataRuleFormulaLint(args.dataRuleType, args.options, args.allowAdvisory);
964
976
  assertDataRuleCheckType(args.dataRuleType, args.checkType);
965
977
  const id = await locateDataRuleId(session, args, "update POST");
966
978
  const before = await listDataRules(session, args.schemaCode);
@@ -41,6 +41,61 @@ export async function modelSchemaCodes(session) {
41
41
  .map((row) => String(row.schemaCode ?? row.bizSchemaCode ?? row.code ?? ""))
42
42
  .filter((code) => code.length > 0);
43
43
  }
44
+ function relationTypeOf(row) {
45
+ const value = Number(row.type ?? row.propertyType);
46
+ return Number.isInteger(value) ? value : undefined;
47
+ }
48
+ /** Semantic relation gate: the designer only offers published business fields. */
49
+ async function assertRelationOptionsFields(session, schemaCode, propertyType, relativeCode, options) {
50
+ const type = typeof propertyType === "number" ? propertyType : FIELD_PROPERTY_TYPES[String(propertyType ?? "")];
51
+ if (type !== FIELD_PROPERTY_TYPES.WORK_SHEET && type !== FIELD_PROPERTY_TYPES.MULT_WORK_SHEET)
52
+ return;
53
+ const target = String(relativeCode ?? options.schemaCode ?? "").trim();
54
+ if (!target)
55
+ return;
56
+ const targetRows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: target, isPublish: "true" } })).data);
57
+ const ownRows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
58
+ const business = (rows) => rows
59
+ .map((row) => ({ code: String(row.code ?? row.propertyCode ?? "").trim(), type: relationTypeOf(row) }))
60
+ .filter((row) => row.code && !SYSTEM_FIELD_CODES.has(row.code));
61
+ const targetFields = business(targetRows);
62
+ const ownFields = business(ownRows);
63
+ // A compatibility/fake transport may expose an empty field face even though
64
+ // the model exists. Empty metadata is not proof that a requested field is
65
+ // absent; preserve the existing relation write path rather than rejecting a
66
+ // valid payload on an unavailable read face.
67
+ if (targetRows.length === 0 || ownRows.length === 0)
68
+ return;
69
+ const candidates = targetFields.map((row) => row.code).sort();
70
+ const display = typeof options.displayField === "string" ? options.displayField.trim() : "";
71
+ if (display) {
72
+ const field = targetFields.find((row) => row.code === display);
73
+ const declaredType = options.displayFieldType === undefined ? undefined : Number(options.displayFieldType);
74
+ if (!field || (declaredType !== undefined && Number.isInteger(declaredType) && field.type !== undefined && field.type !== declaredType)) {
75
+ throw new ToolRefusalError({ status: "refused", blocker: "relation-display-field-not-found", schemaCode, relationTarget: target, displayField: display, candidates, message: `关联显示字段 ${target}.${display} 不存在于发布面或类型不兼容;请从目标模型业务字段中选择。候选:${candidates.join("、") || "(无)"}` });
76
+ }
77
+ }
78
+ let mappings = options.mappings;
79
+ if (typeof mappings === "string" && mappings) {
80
+ try {
81
+ mappings = JSON.parse(mappings);
82
+ }
83
+ catch {
84
+ mappings = [];
85
+ }
86
+ }
87
+ if (!Array.isArray(mappings))
88
+ return;
89
+ const sourceCandidates = targetFields.map((row) => row.code).sort();
90
+ const targetCandidates = ownFields.map((row) => row.code).sort();
91
+ for (const [index, row] of mappings.entries()) {
92
+ const source = row && typeof row === "object" ? String(row.source ?? "").trim() : "";
93
+ const mappedTarget = row && typeof row === "object" ? String(row.target ?? "").trim() : "";
94
+ if (!sourceCandidates.includes(source) || !targetCandidates.includes(mappedTarget)) {
95
+ throw new ToolRefusalError({ status: "refused", blocker: "relation-mapping-field-not-found", schemaCode, relationTarget: target, mappingIndex: index, source, targetField: mappedTarget, sourceCandidates, targetCandidates, message: `关联映射第 ${index + 1} 行字段不存在:source=${source || "(空)"}(目标模型候选:${sourceCandidates.join("、") || "(无)"}),target=${mappedTarget || "(空)"}(本模型候选:${targetCandidates.join("、") || "(无)"})。请使用发布面业务字段 code。` });
96
+ }
97
+ }
98
+ }
44
99
  async function readFieldRollback(session, args) {
45
100
  for (const published of [false, true]) {
46
101
  const row = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: String(args.schemaCode), isPublish: String(published) } })).data).find((entry) => String(entry.code ?? entry.propertyCode) === String(args.code));
@@ -266,7 +321,7 @@ export function registerFieldLane(tools) {
266
321
  // 不带这个键,于是不会出现;write-only 的确认走 field.update 的
267
322
  // readback.committed。
268
323
  ...(Object.prototype.hasOwnProperty.call(row, "propertyEmpty") ? { propertyEmpty: row.propertyEmpty } : {}),
269
- ...(options !== undefined ? { options } : {}),
324
+ ...(options !== undefined ? { options, optionsPresent: true } : { optionsPresent: false }),
270
325
  ...(scaleWarning !== undefined ? { shapeWarning: scaleWarning, ...(system ? { origin: "system" } : {}) } : {}),
271
326
  ...(system && scaleWarning === undefined ? { origin: "system" } : {}),
272
327
  };
@@ -292,7 +347,7 @@ export function registerFieldLane(tools) {
292
347
  const projected = detailed
293
348
  ? visible
294
349
  : visible.map((field) => {
295
- const options = (field.options ?? {});
350
+ const options = ("options" in field ? field.options : undefined) ?? {};
296
351
  const conditions = Object.keys(FORM_CONDITION_OPTION_KEYS).filter((key) => {
297
352
  const value = options[key];
298
353
  return key === "readonlyFormula" ? value === true : typeof value === "string" && value.trim() !== "";
@@ -314,6 +369,11 @@ export function registerFieldLane(tools) {
314
369
  ...(conditions.length > 0 ? { conditions } : {}),
315
370
  ...(field.shapeWarning !== undefined ? { shapeWarning: field.shapeWarning } : {}),
316
371
  ...(field.origin !== undefined ? { origin: field.origin } : {}),
372
+ ...(args.full === true
373
+ ? ("options" in field && field.options !== undefined
374
+ ? { options: field.options, optionsPresent: true }
375
+ : { optionsPresent: false })
376
+ : {}),
317
377
  };
318
378
  });
319
379
  return {
@@ -372,6 +432,11 @@ export function registerFieldLane(tools) {
372
432
  ...(conditions.length > 0 ? { conditions } : {}),
373
433
  ...(field.shapeWarning !== undefined ? { shapeWarning: field.shapeWarning } : {}),
374
434
  ...(field.origin !== undefined ? { origin: field.origin } : {}),
435
+ // r46a 接线(2026-11-08,r45 普查 R5a):压缩占位行保留**标量判别键**
436
+ // optionsPresent——不然 >10 行的 full 读回内联面完全看不到 presence
437
+ // 信号,agent 误判「条件没写进去」(S1-G3 预言命中:通用层把「有值
438
+ // 但正文外置」和「确实没有」混在一起)。正文仍在 artifact。
439
+ ...(field.optionsPresent !== undefined ? { optionsPresent: field.optionsPresent } : {}),
375
440
  };
376
441
  }),
377
442
  hint: "字段详情(options/subSchema)用 omc call field.list --args '{\"schemaCode\":\"...\",\"published\":true,\"code\":\"<field>\"}'",
@@ -417,6 +482,7 @@ export function registerFieldLane(tools) {
417
482
  // (cpdocs issue #8:缺键让设计器选项弹窗硬崩)。
418
483
  const targetVersion = session.detectedVersion();
419
484
  const prepared = prepareFieldChange({ ...args, ...(targetVersion ? { targetVersion } : {}) });
485
+ await assertRelationOptionsFields(session, args.schemaCode, prepared.propertyType, args.relativeCode, prepared.options);
420
486
  let performed;
421
487
  try {
422
488
  performed = await wireCall(session, { path: ENDPOINTS.fieldCreate, method: "POST", body: fieldChangeWireBody(args, prepared) });
@@ -729,6 +795,7 @@ async function fieldUpdateDryRun(session, args) {
729
795
  ...(args.propertyEmpty !== undefined ? { propertyEmpty: args.propertyEmpty } : {}),
730
796
  };
731
797
  const prepared = prepareFieldUpdate(update);
798
+ await assertRelationOptionsFields(session, args.schemaCode, prepared.propertyType, args.relativeCode ?? record.relativeCode, prepared.options);
732
799
  const currentOptions = decodeJsonObjectColumn(record.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
733
800
  const changes = [];
734
801
  const pushChange = (field, from, to) => {
@@ -1117,6 +1184,19 @@ async function runFieldUpdate(session, args) {
1117
1184
  if (args.choices !== undefined && !same(choiceCodesOf(currentOptions) ?? [], [...args.choices]))
1118
1185
  requestedChanges.push("choices");
1119
1186
  if (requestedChanges.length === 0) {
1187
+ let unchangedSync;
1188
+ let unchangedSyncError;
1189
+ const conditionKeys = new Set(Object.keys(FORM_CONDITION_OPTION_KEYS));
1190
+ if (effectiveOptions !== undefined && [...conditionKeys].some((key) => typeof prepared.options[key] === "string" && String(prepared.options[key]).trim() !== "")) {
1191
+ try {
1192
+ const sync = await syncFormConditionDataRules(session, args.schemaCode, args.code, currentOptions, prepared.options);
1193
+ if (sync.created.length + sync.updated.length + sync.deleted.length + sync.skipped.length > 0)
1194
+ unchangedSync = sync;
1195
+ }
1196
+ catch (error) {
1197
+ unchangedSyncError = error instanceof Error ? error.message : String(error);
1198
+ }
1199
+ }
1120
1200
  return {
1121
1201
  performed: { errcode: 0, unchanged: true },
1122
1202
  readback: {
@@ -1126,6 +1206,7 @@ async function runFieldUpdate(session, args) {
1126
1206
  ...(record.name !== undefined ? { name: record.name } : {}),
1127
1207
  ...(record.name_i18n !== undefined ? { name_i18n: record.name_i18n } : {}),
1128
1208
  availableFields: ["name", "name_i18n", "options", "choices", "relativeCode", "propertyEmpty"],
1209
+ ...(unchangedSync !== undefined ? { formConditionSync: unchangedSync } : unchangedSyncError !== undefined ? { formConditionSync: { error: unchangedSyncError } } : {}),
1129
1210
  },
1130
1211
  };
1131
1212
  }
@@ -296,6 +296,7 @@ export function registerFormLane(tools) {
296
296
  .optional()
297
297
  .describe("把子表列绑进**已有** Sheet201 控件的 columns(issue #24):[{parent:\"items\",fieldCodes:[\"category\"]}]。只追加列,不动其它控件/布局行;无已存布局时退回 scaffold"),
298
298
  rebuild: z.boolean().optional().describe("true 时丢弃现有草稿布局,按已发布字段重新生成(新增字段/子表要用它;默认保留现有草稿)"),
299
+ refreshFromFields: z.boolean().optional().describe("true 时不重建布局,仅按当前已发布字段 options 刷新现有控件派生配置"),
299
300
  fromReadback: z.boolean().optional().describe("true = 本次 draftViewJson/draftAttributesJson 来自 form.get 的**逐字未改动**原文(平台自身布局可能含子表列行/系统行超宽,逐字回写放行)。语义是「回写」不是「我读过」:读过之后改过一个字符就不再是回读,会被拒(去掉本参数按普通写面校验)。只加子表列请用 configure_form 的 childColumns"),
300
301
  allowEmptyLayout: z.boolean().optional().describe("显式放行空布局(\"[]\"/\"{}\")写入;默认拒绝(form-empty-layout),防设计器 Save 清空后照写丢控件"),
301
302
  buttons: z
@@ -457,7 +457,22 @@ export function registerListViewLane(tools) {
457
457
  description: LIST_VIEW_TOOLS["listview.list"].description,
458
458
  inputSchema: { environment: environmentArg, schemaCode: z.string().min(1), capability: capabilityArg },
459
459
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
460
- const rows = wireRows((await wireCall(session, { path: ENDPOINTS.listQueryList, query: { schemaCode: args.schemaCode, clientType: "PC" } })).data);
460
+ let rows;
461
+ try {
462
+ rows = wireRows((await wireCall(session, { path: ENDPOINTS.listQueryList, query: { schemaCode: args.schemaCode, clientType: "PC" } })).data);
463
+ }
464
+ catch (error) {
465
+ if (error instanceof WireError && error.errcode === 50000) {
466
+ try {
467
+ await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
468
+ }
469
+ catch (probe) {
470
+ if (probe instanceof WireError && probe.errcode === 300004)
471
+ return { absent: true, schemaCode: args.schemaCode };
472
+ }
473
+ }
474
+ throw error;
475
+ }
461
476
  return {
462
477
  schemaCode: args.schemaCode,
463
478
  count: rows.length,
@@ -419,7 +419,14 @@ export function registerModelLane(tools) {
419
419
  // 产物缺失/损坏时保持空摘要——不因解引用失败拖垮整个 inspect
420
420
  }
421
421
  }
422
- const datarules = dataruleRows.map((rule) => ({ propertyCode: rule.propertyCode, propertyCodes: rule.propertyCodes, dataRuleType: rule.dataRuleType, name: rule.name, checkType: rule.checkType }));
422
+ const datarules = dataruleRows.map((rule) => {
423
+ const hasCondition = ["condition", "conditions", "conditionJson", "ruleCondition", "formula"].some((key) => {
424
+ const value = rule[key];
425
+ return value !== undefined && value !== null && value !== "" && !(Array.isArray(value) && value.length === 0);
426
+ });
427
+ const auto = rule.name == null && Number(rule.checkType) === 4 && !hasCondition;
428
+ return { propertyCode: rule.propertyCode, propertyCodes: rule.propertyCodes, dataRuleType: rule.dataRuleType, name: rule.name, checkType: rule.checkType, origin: auto ? "auto" : "user" };
429
+ });
423
430
  // Workflow summary: use the PUBLISHED face for the `published` flag. The
424
431
  // draft face's templateType is ALWAYS "DRAFT" (even for a published
425
432
  // workflow — draft and published are separate faces), so deriving
@@ -496,7 +503,10 @@ export function registerModelLane(tools) {
496
503
  : {}),
497
504
  form: result.form,
498
505
  listview: result.listview ? { code: result.listview.code, columnCount: result.listview.columnCount } : undefined,
499
- ruleCodes: (result.rules ?? []).map((rule) => rule.code),
506
+ ruleCodes: Array.isArray(result.rules) ? result.rules.map((rule) => rule.code) : [],
507
+ ...(Array.isArray(result.rules) && result.rules.length > 0
508
+ ? { rules: { truncated: true, totalRules: result.rules.length, nextCommand: `omc call rule.list --args {"schemaCode":"${String(result.schemaCode ?? "")}"}` } }
509
+ : {}),
500
510
  datarules: result.datarules,
501
511
  // r35a-F2:workflow 摘要必须恒有 exists 布尔——compact 投影此前把它丢了,
502
512
  // 真机(走 compact 的分支)于是永远读不到 exists(探针只能靠
@@ -554,8 +554,11 @@ export function registerOrganizationLane(tools) {
554
554
  // A 32-hex id absent from the tree may be a group created moments ago in
555
555
  // this same session; let it through and rely on the visibility backstop.
556
556
  if (!match && args.roleGroupCode) {
557
- throw new ToolInputError({
557
+ throw new ToolRefusalError({
558
+ status: "refused",
559
+ blocker: "role-group-not-found",
558
560
  message: `角色组 ${requested} 不存在。可用组:${groups.map((group) => `${group.code}(id=${group.id},${group.name})`).join(";")}`,
561
+ candidates: groups.map((group) => ({ code: group.code, id: group.id, name: group.name })),
559
562
  });
560
563
  }
561
564
  const roleGroupId = match?.id ?? String(requested);
@@ -1042,11 +1045,11 @@ export function registerOrganizationLane(tools) {
1042
1045
  name: "org.role.delete",
1043
1046
  lane: LANE,
1044
1047
  scope: SCOPES.roleDelete,
1045
- description: "删除角色(`GET organization/role/strike_out_role?ids=`)。平台**拒绝删除仍有成员的角色**(`-1 角色下已经包含用户数据!`,实测 1.1.25):默认先读成员数、非空即拒绝并给出人数;`force:true` 才会先逐人移出再删。**可用 roleCode 代替 roleId**(org.role.list 的码表只有 code)。",
1048
+ description: "删除角色(`GET organization/role/strike_out_role?ids=`)。平台**拒绝删除仍有成员的角色**(`-1 角色下已经包含用户数据!`,实测 1.1.25):默认先读成员并拒绝;拒绝回执带可直接粘贴的 `org.role.removeUsers` 命令。`force:true` 的语义是**先逐人移出全部成员,再删除角色**(不是强行绕过平台校验)。**可用 roleCode 代替 roleId**(org.role.list 的码表只有 code)。",
1046
1049
  inputSchema: {
1047
1050
  roleId: z.string().min(1).optional().describe("角色 id(32 位 hex);与 roleCode 二选一"),
1048
1051
  roleCode: z.string().min(1).optional().describe("角色 code(org.role.list 的 roles 码表,如 qc_manager)或名称"),
1049
- force: z.boolean().optional().describe("true = 先移出全部成员再删角色"),
1052
+ force: z.boolean().optional().describe("true = 先逐人调用 removeUsers 移出全部成员,再删角色;不会保留成员"),
1050
1053
  },
1051
1054
  execute: async (session, args) => {
1052
1055
  const roleId = await resolveRoleRef(session, { roleId: args.roleId, roleCode: args.roleCode }, "org.role.delete");
@@ -1054,7 +1057,7 @@ export function registerOrganizationLane(tools) {
1054
1057
  if (rows.length > 0 && args.force !== true)
1055
1058
  throw new ToolRefusalError({
1056
1059
  status: "refused", blocker: "role-has-members", roleId, members: rows.length,
1057
- nextCommand: `omc call org.role.delete --args '${JSON.stringify({ roleId, force: true })}'`,
1060
+ nextCommand: `omc call org.role.removeUsers --args '${JSON.stringify({ roleId, ids: rows.map(roleUserId) })}'`,
1058
1061
  message: `角色下还有 ${rows.length} 名成员,平台拒绝删除(errmsg:角色下已经包含用户数据)。先 org.role.removeUsers,或传 force:true 让工具移出后再删。`,
1059
1062
  });
1060
1063
  if (rows.length > 0)