@stalfh233/omc-cli 0.4.101 → 0.5.0

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.
@@ -128,6 +128,8 @@ async function runScenario(scenario) {
128
128
  };
129
129
  }
130
130
  export async function runDiscoveryCostBaseline() {
131
+ // r29(2026-10-29):describe 重复提示读项目 ledger,组合测试顺序会改变它——测量面禁用保证字节确定性。
132
+ process.env.OMC_DESC_REPEAT_HINT = "0";
131
133
  const scenarios = [];
132
134
  for (const scenario of SCENARIOS)
133
135
  scenarios.push(await runScenario(scenario));
package/dist/cli.js CHANGED
@@ -17,8 +17,8 @@ import { cmdModelPurge } from "./model-purge.js";
17
17
  import { environmentWritable, requiresProductionConfirmation } from "./environment-policy.js";
18
18
  import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, refusalText, requestId } from "./cli-contract.js";
19
19
  import { configureOutput, humanSummary, stripOutputFlags, writeEnvelope } from "./cli-output.js";
20
- import { beginUsage, completeUsage, ensureProjectIgnore, recordUsageOutput, usageReport } from "./usage-log.js";
21
- import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
20
+ import { beginUsage, completeUsage, currentUsageArgsDigest, ensureProjectIgnore, priorIdenticalSuccess, recordUsageOutput, usageReport } from "./usage-log.js";
21
+ import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitThrown, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
22
22
  import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath, pruneArtifacts } from "./artifacts.js";
23
23
  import { projectJsonPath, projectJsonPathFanout, projectResultExtract } from "./path-projection.js";
24
24
  import { basename, join, resolve } from "node:path";
@@ -44,8 +44,11 @@ import { planIntent, readbackIntent, applyIntent, intentWarnings } from "./inten
44
44
  import { listSkills, readSkill, readSkillOutline, readSkillSection, searchSkills } from "./skills.js";
45
45
  import { parseToolInput, ToolInputError } from "./tool-types.js";
46
46
  import { WireError, wireCall } from "./wire.js";
47
+ import { DEFAULT_PULL_CONCURRENCY, GraphIndexAbsentError, GraphPullLockHeldError, MAX_PULL_CONCURRENCY, impactGraph, publishGraphPull, queryGraphReferences, readGraphStatus, searchGraphRules, } from "./graph.js";
47
48
  import { ENDPOINTS } from "./endpoints.js";
49
+ import { makeToolContext } from "./context.js";
48
50
  import { cmdRollbackList, cmdRollbackPlan, cmdRollbackApply } from "./rollback.js";
51
+ import { SessionManager } from "./session-manager.js";
49
52
  async function ask(question) {
50
53
  const rl = createInterface({ input: stdin, output: process.stderr });
51
54
  try {
@@ -879,8 +882,20 @@ function cmdDiscovery(kind, transport, value, options = {}) {
879
882
  // second identical read returns an artifact reference instead of the body.
880
883
  result = full ? (repeatFullDescribe(value ?? "", full) ?? full) : full;
881
884
  }
882
- else
885
+ else {
883
886
  result = describeTool(registry, value ?? "");
887
+ // r28b 账本(2026-10-28):describe rule.save×11 / form.publish×9——firstCall 教学后
888
+ // 仍反复全量 describe。同参数近期已 ok 时附降载提示,引导 --param/--example 精准取。
889
+ // 无项目 ledger(golden/repo 测试/一次性运行)时 priorIdenticalSuccess 返回
890
+ // undefined,输出保持 byte-stable。
891
+ if (result) {
892
+ const digest = currentUsageArgsDigest();
893
+ const prior = digest === undefined ? undefined : priorIdenticalSuccess(`describe ${String(value ?? "")}`, digest);
894
+ if (prior !== undefined && prior.count >= 1)
895
+ result.describeRepeatHint =
896
+ `同参数 describe 近期已执行过 ${prior.count + 1} 次(含本次)。在找单个参数:omc describe ${String(value ?? "")} --param <参数名>(几百字节);找可抄的完整例子:--example <配方id>;都不必重读整面`;
897
+ }
898
+ }
884
899
  if (!result) {
885
900
  // Name the closest candidates so a near-miss tool name does not cost a
886
901
  // guess-and-retry call (round-4 dogfood: `form.create` / `data.create` /
@@ -1029,6 +1044,106 @@ async function cmdIntentReadback(file, environment, transport) {
1029
1044
  return read.code;
1030
1045
  return emitResult(await readbackIntent(read.intent, environment), transport);
1031
1046
  }
1047
+ /**
1048
+ * ADR-0008 — `omc graph` family: the local derived rule-graph index. A CLI
1049
+ * management face like `intent`/`rollback`: NOT a call tool, not in the tool
1050
+ * registry or `commands --json`. Results are candidate pointers only — plan /
1051
+ * apply / readback keep reading the live baseline.
1052
+ */
1053
+ async function cmdGraphPull(environment, concurrency, transport) {
1054
+ const workers = concurrency ?? DEFAULT_PULL_CONCURRENCY;
1055
+ if (!Number.isSafeInteger(workers) || workers < 1 || workers > MAX_PULL_CONCURRENCY)
1056
+ return emitInvalid(transport, { message: `--concurrency 必须是 1–${MAX_PULL_CONCURRENCY} 的整数(平台负载有界;默认 ${DEFAULT_PULL_CONCURRENCY})` });
1057
+ const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
1058
+ try {
1059
+ const session = await context.sessions.session(environment);
1060
+ const data = await publishGraphPull(session, workers);
1061
+ const edgesByKind = {};
1062
+ for (const edge of data.edges)
1063
+ edgesByKind[edge.kind] = (edgesByKind[edge.kind] ?? 0) + 1;
1064
+ const gapsByKind = {};
1065
+ for (const gap of data.gaps)
1066
+ gapsByKind[gap.kind] = (gapsByKind[gap.kind] ?? 0) + 1;
1067
+ return emitResult({
1068
+ status: "ok",
1069
+ environment: data.meta.environmentName,
1070
+ applicationCode: data.meta.applicationCode,
1071
+ identityFingerprint: data.meta.identityFingerprint,
1072
+ pulledAtIso: data.meta.pulledAtIso,
1073
+ scope: data.meta.scope,
1074
+ freshness: data.gaps.length === 0 ? "complete" : "partial",
1075
+ scanned: { models: data.scanned.models, rules: data.scanned.rules, datarules: data.scanned.datarules, views: data.scanned.views, ...(data.scanned.parseFailures > 0 ? { parseFailures: data.scanned.parseFailures } : {}) },
1076
+ edges: edgesByKind,
1077
+ ...(data.gaps.length > 0 ? { gaps: { count: data.gaps.length, byKind: gapsByKind } } : {}),
1078
+ dbBytes: data.dbBytes,
1079
+ timings: data.timings,
1080
+ nextCommand: "omc graph references <kind> <code>",
1081
+ }, transport);
1082
+ }
1083
+ catch (error) {
1084
+ if (error instanceof GraphPullLockHeldError)
1085
+ return emitResult({ status: "refused", blocker: "graph-pull-lock-held", message: error.message, nextCommand: "omc graph status" }, transport);
1086
+ if (error instanceof WireError)
1087
+ return emitResult({ status: "uncertain", reason: "wire-transport", message: `graph pull 中断(${error.message});索引未发布,重跑 omc graph pull --env <env>` }, transport);
1088
+ return emitThrown(error, transport);
1089
+ }
1090
+ }
1091
+ function cmdGraphReferences(kind, code, transport) {
1092
+ if (!["service", "model", "field"].includes(kind))
1093
+ return emitInvalid(transport, { message: "kind 必须是 service / model / field(同 rule.references 第一版边界)" });
1094
+ try {
1095
+ const result = queryGraphReferences(kind, code);
1096
+ return emitResult({
1097
+ status: "ok",
1098
+ kind: result.kind,
1099
+ code: result.code,
1100
+ matches: result.matches,
1101
+ index: result.index,
1102
+ ...(result.note !== undefined ? { note: result.note } : {}),
1103
+ ...(result.gapWarning !== undefined ? { gapWarning: result.gapWarning } : {}),
1104
+ }, transport);
1105
+ }
1106
+ catch (error) {
1107
+ if (error instanceof GraphIndexAbsentError)
1108
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1109
+ return emitThrown(error, transport);
1110
+ }
1111
+ }
1112
+ function cmdGraphStatus(transport) {
1113
+ try {
1114
+ const status = readGraphStatus();
1115
+ return emitResult({ status: "ok", index: status.index, dbBytes: status.dbBytes, gaps: status.gaps }, transport);
1116
+ }
1117
+ catch (error) {
1118
+ if (error instanceof GraphIndexAbsentError)
1119
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1120
+ return emitThrown(error, transport);
1121
+ }
1122
+ }
1123
+ function cmdGraphImpact(kind, code, transport) {
1124
+ if (!["service", "model", "field"].includes(kind))
1125
+ return emitInvalid(transport, { message: "kind 必须是 service / model / field(同 rule.references 第一版边界)" });
1126
+ try {
1127
+ const result = impactGraph(kind, code);
1128
+ return emitResult({ status: "ok", ...result }, transport);
1129
+ }
1130
+ catch (error) {
1131
+ if (error instanceof GraphIndexAbsentError)
1132
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1133
+ return emitThrown(error, transport);
1134
+ }
1135
+ }
1136
+ function cmdGraphSearch(term, transport) {
1137
+ try {
1138
+ const result = searchGraphRules(term);
1139
+ return emitResult({ status: "ok", ...result }, transport);
1140
+ }
1141
+ catch (error) {
1142
+ if (error instanceof GraphIndexAbsentError)
1143
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1144
+ return emitThrown(error, transport);
1145
+ }
1146
+ }
1032
1147
  /** Ticket 119 — `omc intent apply <plan-file>`: plan integrity + baseline drift gates, then serial execution through the EXISTING apply_changes machinery. */
1033
1148
  async function cmdIntentApply(planPath, options, transport) {
1034
1149
  return applyIntent(planPath, options, {
@@ -1508,7 +1623,7 @@ async function main() {
1508
1623
  .option("--schema <schemaCode>", "模型 code")
1509
1624
  .option("--schema-code <schemaCode>")
1510
1625
  .option("--rule-code <ruleCode>")
1511
- .option("--name <name>")
1626
+ .option("--name <name>", "规则显示名(rule.save 的 name 位;与既有规则同名则覆盖更新)")
1512
1627
  .option("--node-graph <json>", "规则图 {nodes,routes} JSON")
1513
1628
  .option("--publish", "保存后同时发布规则版本")
1514
1629
  .option("--env <environment>")
@@ -1714,6 +1829,25 @@ async function main() {
1714
1829
  intentCommand.command("readback <file>").option("--env <environment>").option("--max-output-bytes <bytes>").action(async (file, options) => {
1715
1830
  commandExitCode = await withTransport(options, request, (transport) => cmdIntentReadback(file, options.env, transport));
1716
1831
  });
1832
+ // ADR-0008: local derived rule-graph index — CLI management face, not a call
1833
+ // tool (so it stays out of `commands --json`/coverage by construction).
1834
+ const graphCommand = command.command("graph").description("本地派生规则图索引(ADR-0008):pull 全量重建(有界并发)/ references 离线引用 / impact 影响面 / search 规则全文 / status 完整性与新鲜度。候选定位器——不进 baseline/plan/写门禁");
1835
+ graphCommand.command("pull").option("--env <environment>").option("--environment <environment>").option("--concurrency <n>", "并发 wire 读数(1–32,默认 16):拉取已流水线化,墙钟 ≈ max(模型面,规则面)/并发 × 延迟;过高可能触发平台限流形成缺口").option("--max-output-bytes <bytes>").addHelpText("after", "\n只读远端(model.list → 每模型 rule.list+datarule.list → 每规则 rule.get,统一任务池流水线并发)+ 本地原子重建;不写环境、不占环境写锁。新鲜度随结果回报(complete|partial),查询走 graph references / impact / search。\n").action(async (options) => {
1836
+ const parsed = options.concurrency === undefined ? undefined : Number.parseInt(String(options.concurrency), 10);
1837
+ commandExitCode = await withTransport(options, request, (transport) => cmdGraphPull(options.environment ?? options.env, parsed, transport));
1838
+ });
1839
+ graphCommand.command("references <kind> <code>").option("--max-output-bytes <bytes>").addHelpText("after", "\n离线查询(零 wire):kind = service|model|field,语义同 rule.references 第一版边界;结果带索引新鲜度与扫描缺口(有缺口时零命中不构成「无引用」证明)。\n").action(async (kind, code, options) => {
1840
+ commandExitCode = await withTransport(options, request, (transport) => cmdGraphReferences(kind, code, transport));
1841
+ });
1842
+ graphCommand.command("impact <kind> <code>").option("--max-output-bytes <bytes>").addHelpText("after", "\n影响面(离线):跳1=引用该编码的规则(同 references),跳2=这些规则还读写哪些 service/model/field——改前看爆炸半径。截断上限 50 规则×每类 20 码。\n").action(async (kind, code, options) => {
1843
+ commandExitCode = await withTransport(options, request, (transport) => cmdGraphImpact(kind, code, transport));
1844
+ });
1845
+ graphCommand.command("search <term...>").option("--max-output-bytes <bytes>").addHelpText("after", "\n规则全文检索(code+name,FTS5,末词前缀匹配):找到\"关于 X 的规则\"拿 code,再走 references/impact。中文需给全词或词首(如 报名*)。\n").action(async (term, options) => {
1846
+ commandExitCode = await withTransport(options, request, (transport) => cmdGraphSearch(term.join(" "), transport));
1847
+ });
1848
+ graphCommand.command("status").option("--max-output-bytes <bytes>").action(async (options) => {
1849
+ commandExitCode = await withTransport(options, request, (transport) => cmdGraphStatus(transport));
1850
+ });
1717
1851
  // `--jq` expressions the shell split arrive as several tokens; re-join them.
1718
1852
  await command.parseAsync(joinSplitFlagValues(stripOutputFlags(argv), ["--jq"]));
1719
1853
  return commandExitCode;
@@ -1742,7 +1876,7 @@ main().then((code) => {
1742
1876
  // envelope (or, for `env --json`, their own JSON) and were printing a second
1743
1877
  // empty envelope after it (live 2026-10-23: `omc rollback apply` answered two
1744
1878
  // lines, violating the one-envelope stdout contract).
1745
- const ownsStdout = ["call", "script", "artifact", "commands", "describe", "contract", "capabilities", "capability", "next", "usage", "intent", "help", "form-default", "compute", "rule-when", "approval-flow", "model-scaffold", "model-purge", "rollback", "env", "--version", "-V"];
1879
+ const ownsStdout = ["call", "script", "artifact", "commands", "describe", "contract", "capabilities", "capability", "next", "usage", "intent", "help", "form-default", "compute", "rule-when", "approval-flow", "model-scaffold", "model-purge", "rollback", "env", "graph", "--version", "-V"];
1746
1880
  if (ownsStdout.includes(argv[2] ?? "")) {
1747
1881
  completeUsage(code);
1748
1882
  finish(code);
@@ -225,18 +225,25 @@ export async function runRuleSaveEnable(options) {
225
225
  * droppedNodes 列明。
226
226
  * 两种路径都不依赖平台的合并/整替行为:最终模板由本命令显式合成。
227
227
  */
228
+ /**
229
+ * 节点码/类型的读取兼容真机与 fake 两种投影(r22 2026-10-27 真机实证:
230
+ * workflow.get_draft 真机节点形如 {"index":0,"code":"Activity1","type":"START"},
231
+ * 而本命令原读法 activityCode/activityType 恒空 → preservedNodes 恒空)。
232
+ */
233
+ const activityCodeOf = (activity) => String(activity.activityCode ?? activity.code ?? "");
234
+ const activityTypeOf = (activity) => String(activity.activityType ?? activity.type ?? "").toUpperCase();
228
235
  function composeFinalTemplate(draftActivities, template, confirmReplace) {
229
- const newCodes = new Set((template.activities ?? []).map((activity) => String(activity.activityCode ?? "")));
236
+ const newCodes = new Set((template.activities ?? []).map((activity) => activityCodeOf(activity)));
230
237
  const oldNodes = draftActivities.filter((activity) => {
231
- const code = String(activity.activityCode ?? "");
238
+ const code = activityCodeOf(activity);
232
239
  return code !== "" && !newCodes.has(code);
233
240
  });
234
- const oldCodes = oldNodes.map((activity) => String(activity.activityCode ?? ""));
241
+ const oldCodes = oldNodes.map((activity) => activityCodeOf(activity));
235
242
  if (confirmReplace || oldCodes.length === 0) {
236
243
  return { finalTemplate: template, preservedNodes: [], droppedNodes: confirmReplace ? oldCodes : [] };
237
244
  }
238
245
  const activities = template.activities ?? [];
239
- const endIndex = activities.findIndex((activity) => String(activity.activityType ?? "").toUpperCase() === "END");
246
+ const endIndex = activities.findIndex((activity) => activityTypeOf(activity) === "END");
240
247
  const merged = endIndex >= 0
241
248
  ? [...activities.slice(0, endIndex), ...oldNodes, ...activities.slice(endIndex)]
242
249
  : [...activities, ...oldNodes];
@@ -251,7 +258,11 @@ export async function runWorkflowUpdatePublish(options) {
251
258
  }
252
259
  const draft = options.apply ? await invoke("workflow.get_draft", { schemaCode: options.schema, ...(options.code ? { code: options.code } : {}) }) : { status: "ok", data: null };
253
260
  const draftData = (draft.data ?? null);
254
- const draftActivities = (draftData?.activities ?? draftData?.template?.activities ?? []);
261
+ // r22 真机实证:真机投影在 workflowSummary.activities;fake 在 activities/template.activities——三口径全兼容。
262
+ const draftActivities = (draftData?.workflowSummary?.activities
263
+ ?? draftData?.activities
264
+ ?? draftData?.template?.activities
265
+ ?? []);
255
266
  const { finalTemplate, preservedNodes, droppedNodes } = composeFinalTemplate(draftActivities, options.template, options.confirmReplace === true);
256
267
  if (!options.apply) {
257
268
  emit({
@@ -2,6 +2,7 @@ import { FIELD_PROPERTY_TYPES } from "./field-families.js";
2
2
  import { assertFormConditions, assertNumericalScale, assertFieldOptionsAllowed, buildChildTableSubSchema, buildFieldOptions, ensureRelationDisplayFieldOptions, normalizeFieldPropertyType, relationDisplayFieldCode, } from "./field-families.js";
3
3
  import { nameI18nJson } from "./field-families.js";
4
4
  import { decodeJsonObjectColumn } from "./json-column.js";
5
+ import { normalizeFormConditionOptions } from "./lanes/datarule-protocol.js";
5
6
  import { ToolRefusalError } from "./tool-types.js";
6
7
  /**
7
8
  * Fail-closed relation-target gate (round17 dogfood R3). A relation field whose
@@ -75,6 +76,10 @@ export function fieldChangeWireBody(input, prepared) {
75
76
  };
76
77
  }
77
78
  export function prepareFieldUpdate(input) {
79
+ // r26 快速装载:符号式/裸值条件就地编译为平台中文规范形状(合规式零改写)。
80
+ const normalizedOptions = input.options === undefined ? undefined : normalizeFormConditionOptions(input.options);
81
+ const effectiveOptions = normalizedOptions?.options;
82
+ const conditionCompiled = normalizedOptions?.compiledNotes ?? [];
78
83
  const recordType = Number(input.base.type ?? input.base.propertyType);
79
84
  const isChildTable = recordType === FIELD_PROPERTY_TYPES.CHILD_TABLE;
80
85
  if (isChildTable && (input.options !== undefined || input.choices !== undefined || input.subFields !== undefined))
@@ -86,17 +91,33 @@ export function prepareFieldUpdate(input) {
86
91
  message: `子表字段 ${input.schemaCode}.${input.code} 不接受 options/choices/subFields。子表列就是 schemaCode=${input.code} 上的普通字段:增列用 field.create --args '{"schemaCode":"${input.code}","code":"<子列>",...}',改列/删列用 field.update/field.delete(同 schemaCode),改完对父模型 field.publish 即发布`,
87
92
  });
88
93
  const currentOptions = decodeJsonObjectColumn(input.base.options, { invalid: "throw", array: "accept", object: "accept" }) ?? {};
89
- const nextOptions = isChildTable
94
+ const rebuiltOptions = isChildTable
90
95
  ? currentOptions
91
96
  : ensureRelationDisplayFieldOptions(recordType, {
92
97
  ...currentOptions,
93
98
  ...buildFieldOptions({
94
99
  propertyType: recordType,
95
100
  ...(input.choices ? { choices: input.choices } : {}),
96
- ...(input.options ? { options: input.options } : {}),
101
+ ...(effectiveOptions ? { options: effectiveOptions } : {}),
97
102
  }),
98
103
  });
99
- if (!isChildTable && !input.options && !input.choices)
104
+ // 2026-10-29 防御(治豪 issue 的 optionsSet 覆盖风险):带 choices 更新选项族时
105
+ // optionsSet 走保留式合并——以服务端原文 optionsSet 为基线,只换 custom 条目,
106
+ // optionStructure/treeSelect/businessModel 等未声明键(含跨版本导入态)一律继承,
107
+ // 不整体重建。锚定线(8.6/1.1.25)实测无此形状,纯防御。
108
+ const nextOptions = isChildTable
109
+ ? currentOptions
110
+ : input.choices !== undefined
111
+ && rebuiltOptions.optionsSet !== undefined
112
+ && currentOptions.optionsSet !== undefined
113
+ && currentOptions.optionsSet !== null
114
+ && typeof currentOptions.optionsSet === "object"
115
+ ? {
116
+ ...rebuiltOptions,
117
+ optionsSet: { ...currentOptions.optionsSet, ...rebuiltOptions.optionsSet },
118
+ }
119
+ : rebuiltOptions;
120
+ if (!isChildTable && !effectiveOptions && !input.choices)
100
121
  Object.assign(nextOptions, currentOptions);
101
122
  if (!isChildTable) {
102
123
  // An update that touches a relation's options must keep a resolvable target:
@@ -104,10 +125,10 @@ export function prepareFieldUpdate(input) {
104
125
  // field.publish cannot later 300010 on this field.
105
126
  assertRelationTargetPresent(recordType, nextOptions, input.relativeCode ?? (typeof input.base.relativeCode === "string" ? input.base.relativeCode : undefined), input.code);
106
127
  assertFieldOptionsAllowed(recordType, nextOptions, input.schemaCode);
107
- if (input.options !== undefined || input.choices !== undefined)
128
+ if (effectiveOptions !== undefined || input.choices !== undefined)
108
129
  assertNumericalScale(recordType, nextOptions);
109
- if (input.options !== undefined)
110
- assertFormConditions(input.options);
130
+ if (effectiveOptions !== undefined)
131
+ assertFormConditions(effectiveOptions);
111
132
  }
112
133
  // Ticket 61: an explicit displayField wins; otherwise preserve the stored
113
134
  // top-level relativePropertyCode (the runtime reads it, not options).
@@ -119,7 +140,7 @@ export function prepareFieldUpdate(input) {
119
140
  : input.name !== undefined
120
141
  ? nameI18nJson(input.name)
121
142
  : (typeof input.base.name_i18n === "string" ? input.base.name_i18n : undefined);
122
- return { propertyType: recordType, isChildTable, options: nextOptions, ...(displayCode !== undefined ? { displayCode } : {}), ...(name !== undefined ? { name } : {}), ...(nameI18n !== undefined ? { nameI18n } : {}) };
143
+ return { propertyType: recordType, isChildTable, options: nextOptions, ...(conditionCompiled.length > 0 ? { conditionCompiled } : {}), ...(displayCode !== undefined ? { displayCode } : {}), ...(name !== undefined ? { name } : {}), ...(nameI18n !== undefined ? { nameI18n } : {}) };
123
144
  }
124
145
  /** The single field-update wire body: the stored row spread with changed keys. */
125
146
  export function fieldUpdateWireBody(input, prepared) {
@@ -167,7 +167,7 @@ export const fieldOptionsArg = z
167
167
  schemaCode: z.string().optional().describe("relation target model code (WORK_SHEET / MULT_WORK_SHEET)"),
168
168
  queryCode: z.string().optional().describe("relation query code (usually the target model code)"),
169
169
  displayField: z.string().optional().describe("relation display field (usually 'name')"),
170
- mappings: z.string().optional().describe('relation carry mappings, e.g. [{"source":"<target field code>","target":"<this-model field code>"}]'),
170
+ mappings: z.union([z.string(), z.array(z.object({ source: z.string(), target: z.string() }))]).optional().describe('relation carry mappings——数组 [{"source":"<target field code>","target":"<this-model field code>"}] 或等价 JSON 字符串均可,CLI 就地归一(空数组/"[]" → 无映射)'),
171
171
  searchFormula: jsonString().optional().describe("WORK_SHEET / MULT_WORK_SHEET data restriction range JSON"),
172
172
  conditions: jsonString().optional().describe("WORK_SHEET / MULT_WORK_SHEET query conditions JSON"),
173
173
  optionsSet: optionsSetArg.optional().describe("selection source: custom, dictionary, or businessModel"),
@@ -495,6 +495,64 @@ function normalizeBusinessModelCondition(businessModel) {
495
495
  if (parts.length > 0)
496
496
  businessModel.condition = parts.join("&&");
497
497
  }
498
+ /**
499
+ * r26 快速装载(2026-10-28):关联带出 mappings 的就地归一。agent 最自然的
500
+ * 写法是数组 `mappings:[{source:"contract_name",target:"contract_name"}]`——
501
+ * 此前静默失效(wire 成数组形态,平台只认 JSON 字符串),必须照 describe
502
+ * 拼字符串(r24/r26 各 ~15 轮试错的来源之一)。归一规则:
503
+ * - 数组:空数组 → `""`(无映射规范形态,规避 `"[]"` 假"已设置"坑);
504
+ * 非空逐项必须是 `{source,target}` 非空字符串 → 紧凑 JSON 字符串
505
+ * - 字符串:`"[]"` → `""`;可 JSON.parse 的必须解析为 `[{source,target}]`
506
+ * 形状(坏结构教学拒绝);parse 失败按原样拒绝
507
+ * - 其它类型拒绝教学
508
+ */
509
+ export function normalizeMappingsOption(options) {
510
+ if (!("mappings" in options) || options.mappings === undefined)
511
+ return { options };
512
+ const mappings = options.mappings;
513
+ const mappingsError = (detail) => refuseFieldOptions(`relation mappings fail the reproduced protocol`, `rejected: options.mappings ${detail};合法形状:JSON 字符串 [{"source":"<来源字段>","target":"<目标字段>"}] 或等价数组;无映射用 ""(不是 "[]"——"[]" 会让设计器显示"已设置"但弹窗无映射行)`);
514
+ let normalized;
515
+ if (Array.isArray(mappings)) {
516
+ if (mappings.length === 0)
517
+ normalized = "";
518
+ else {
519
+ const rows = mappings.map((row) => {
520
+ if (row === null || typeof row !== "object" || Array.isArray(row))
521
+ mappingsError("数组每项必须是 {source,target} 对象");
522
+ const record = row;
523
+ const source = record.source;
524
+ const target = record.target;
525
+ if (typeof source !== "string" || source.trim() === "" || typeof target !== "string" || target.trim() === "")
526
+ mappingsError("每项的 source/target 必须是非空字符串");
527
+ return { source: source.trim(), target: target.trim() };
528
+ });
529
+ normalized = JSON.stringify(rows);
530
+ }
531
+ }
532
+ else if (typeof mappings === "string") {
533
+ const trimmed = mappings.trim();
534
+ if (trimmed === "" || trimmed === "[]")
535
+ normalized = "";
536
+ else {
537
+ let parsed;
538
+ try {
539
+ parsed = JSON.parse(trimmed);
540
+ }
541
+ catch {
542
+ mappingsError(`字符串必须可解析为 JSON 数组(收到:${trimmed.slice(0, 40)}…)`);
543
+ }
544
+ 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 === ""))
545
+ mappingsError("解析结果必须是 [{source,target}] 形状(source/target 非空字符串)");
546
+ normalized = JSON.stringify(parsed.map(({ source, target }) => ({ source: source.trim(), target: target.trim() })));
547
+ }
548
+ }
549
+ else
550
+ mappingsError(`必须是 JSON 字符串或数组(收到 ${typeof mappings})`);
551
+ const next = { ...options, mappings: normalized };
552
+ // note 只在「形态」变化(数组→字符串 / "[]"→"")时给;纯 trim 不算。
553
+ const shapeChanged = Array.isArray(mappings) || (typeof mappings === "string" && mappings.trim() === "[]");
554
+ return { options: next, ...(shapeChanged ? { note: `options.mappings 已归一为平台字符串形态:${normalized === "" ? "空串(无映射)" : String(normalized)}` } : {}) };
555
+ }
498
556
  export function buildFieldOptions(args) {
499
557
  const explicit = args.options;
500
558
  const raw = args.propertyType;
@@ -513,7 +571,7 @@ export function buildFieldOptions(args) {
513
571
  refuseFieldOptions("selection field options carry no optionsSet source", `rejected: ${choiceError}`);
514
572
  }
515
573
  normalizeBusinessModelOrderBy(explicit);
516
- return explicit;
574
+ return normalizeMappingsOption(explicit).options;
517
575
  }
518
576
  const choices = args.choices;
519
577
  if (Array.isArray(choices) && choices.length > 0) {
@@ -561,6 +561,43 @@ export function goalRoute(registry, goal) {
561
561
  // 对控件条件必拒;唯一路径是表单 attributes(form.get 读 draftAttributesJson
562
562
  // 定位控件 → form.draft 改 → form.publish)。此前该场景路由进 field→form 梯子
563
563
  // 且首级无可行工具(firstCommands 为空),agent 只能靠拒收文案自己悟。
564
+ // r27 探针(2026-10-28):普通字段的条件必填/显隐/只读——「为字段配置条件必填…规则」
565
+ // 这类措辞被判成 read-ladder("要查看哪个对象?"),agent 被迫自行探索并先撞
566
+ // datarule.create(配方明令禁止的路线)再折回 field.update,单意图浪费约 15 次调用。
567
+ // 触发词面:条件必填/必填条件/条件显隐/显示条件/隐藏条件/条件只读/只读条件 +
568
+ // 配置/设置/改成/修改/加上 等写动词,或直接点名 requiredFormula/displayFormula/
569
+ // readonlyCondition。不提「子表/控件」(那是上一条表单 attributes 的专项路由)。
570
+ if (!/子表/.test(goal)
571
+ && !/查看|有哪些|是什么|怎么|列出|看看|查询|检查|确认/.test(goal)
572
+ && (/条件必填|必填条件|条件显隐|显隐条件|显示条件|条件显示|隐藏条件|条件隐藏|条件只读|只读条件|requiredFormula|displayFormula|readonlyCondition/.test(goal))
573
+ && /配置|设置|改成|修改|加上|加一个|做个|做一|变必填|控制|当.*时/.test(goal)) {
574
+ return {
575
+ status: "ok",
576
+ goal,
577
+ confident: true,
578
+ mode: "modify",
579
+ write: true,
580
+ match: { kind: "recipe", ref: "form-conditional-required", title: "字段层条件必填/显隐/只读(field.update options 面)" },
581
+ route: {
582
+ capability: "field",
583
+ lane: "field-families",
584
+ note: "普通字段的条件必填(requiredFormula)/条件显隐(displayFormula)/条件只读(readonlyCondition)都在字段 options 面:field.update 写入即自动配对 bizdatarule 3/4/5 行(回执 formConditionSync)。表达式可用自然符号式({字段} = \"值\" / {字段} >= 5000 / {字段} 不为空),CLI 就地编译为平台中文形状。**不要用 datarule.create**——type4 不让门户控件变必填(实测)。",
585
+ readiness: DISCOVERY_AUTHORITY.readiness,
586
+ firstCommands: [
587
+ "omc describe field.update --example form-conditional-required",
588
+ "omc call field.list --args '{\"schemaCode\":\"<schemaCode\",\"full\":true}'",
589
+ ],
590
+ write: true,
591
+ },
592
+ firstAnswer: {
593
+ state: "needs-input",
594
+ question: "目标模型 schemaCode 与字段 code 是什么?(给我 schemaCode 或业务名,我列清单核对)",
595
+ action: LIST_MODELS_ACTION,
596
+ pendingCommand: "omc call field.update --args '{\"schemaCode\":\"<schemaCode>\",\"code\":\"<字段code>\",\"options\":{\"requiredFormula\":\"{触发字段} 等于 某值\"}}'",
597
+ why: "命中字段层条件三件套路由:field.update 的 options.requiredFormula/displayFormula/readonlyCondition(表达式支持符号式就地编译)",
598
+ },
599
+ };
600
+ }
564
601
  if (/子表|控件/.test(goal) && /子表.*(显示|隐藏|只读|显隐).*(条件|公式|规则)|displayFormula|readonlyFormula/.test(goal)) {
565
602
  return {
566
603
  status: "ok",