@stalfh233/omc-cli 0.6.19 → 0.6.21

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.
@@ -386,6 +386,17 @@ export function classifyResult(result) {
386
386
  const nextCommand = nextCommandFor(result) ?? (auth ? undefined : "omc help omc-refusals");
387
387
  return { status: route.envelopeStatus, error: nextCommand === undefined ? error : { ...error, nextCommand }, exitCode: auth ? CLI_EXIT_CODES.authConfig : CLI_EXIT_CODES.policyRefused };
388
388
  }
389
+ // Cross-project environment isolation is a local policy gate. It is emitted
390
+ // as an error-shaped result by the call seam so ordinary ConfigurationError
391
+ // failures (unknown env, missing credentials, malformed config) retain their
392
+ // historical classification.
393
+ if (result.blocker === "cross-environment-policy" || /^跨环境访问被拒绝:/u.test(String(result.message ?? ""))) {
394
+ return {
395
+ status: "error",
396
+ error: withNext({ code: "policy-refused", message: String(result.message ?? "cross-environment access refused"), retryable: false }),
397
+ exitCode: CLI_EXIT_CODES.policyRefused,
398
+ };
399
+ }
389
400
  // r25 沙盒(2026-11-04,B1 的同类):`meta.get_status` 返回 `status:"failed"`、
390
401
  // `rollback apply` 返回 `status:"stopped"`——两个都曾掉进 internal(exit 15):
391
402
  // 调用方看到「CLI 内部故障」,其实是「配置/凭据读回失败」或「恢复链在 N 步停下」。
package/dist/cli.js CHANGED
@@ -802,6 +802,24 @@ async function cmdCall(store, args) {
802
802
  // Do not also copy the single-path value to top-level content.
803
803
  if (extractPaths.length > 0 && !extractKeys && typeof result === "object" && result !== null)
804
804
  delete result.content;
805
+ // r40a: unsupported jq language is an input error, never an `ok:true`
806
+ // projection with a null value. Keep the path engine's detailed hint but
807
+ // make the CLI contract actionable and machine-classifiable.
808
+ if (typeof result === "object" && result !== null && Array.isArray(result.extract)) {
809
+ for (const entry of result.extract)
810
+ delete entry.autoStripped;
811
+ const unsupported = result.extract.filter((entry) => entry.unsupportedSyntax === true);
812
+ if (unsupported.length > 0) {
813
+ const first = unsupported[0];
814
+ const message = `--jq 只接受路径选择器,不支持对象构造或管道语法(${String(first?.path ?? "")})。可运行示例:--jq 'fields[].code,fields[].typeName'`;
815
+ const invalid = { status: "error", blocker: "tool-input-invalid", unsupportedSyntax: true, paths: unsupported.map((entry) => entry.path), message, nextCommand: "omc call <tool> --args '<json>' --jq 'fields[].code,fields[].typeName'" };
816
+ return emit("error", invalid, CLI_EXIT_CODES.invalidInput);
817
+ }
818
+ }
819
+ // `extract[]` is the canonical multi-path result. Do not emit the second
820
+ // row-alignment copy: callers can align the explicitly named path values.
821
+ if (typeof result === "object" && result !== null && extractPaths.length > 1)
822
+ delete result.rows;
805
823
  }
806
824
  // 读面未知键的非阻断回显:放在投影**之后**,否则会被 --jq 的窄投影吞掉。
807
825
  if (ignoredReadKeys.length > 0 && typeof result === "object" && result !== null) {
@@ -1666,7 +1684,7 @@ function cmdGraphReferences(kind, code, transport, requestedEnvironment) {
1666
1684
  }
1667
1685
  catch (error) {
1668
1686
  if (error instanceof GraphIndexAbsentError)
1669
- return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1687
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: `${error.message} 只读核查(不写索引):omc graph status --env <env>(当前配置根);MCP 面工具读的可能是另一配置根,比对 dbId/dbPath。`, nextCommand: "omc graph pull --env <env>" }, transport);
1670
1688
  return emitThrown(error, transport);
1671
1689
  }
1672
1690
  }
@@ -1680,7 +1698,7 @@ function cmdGraphStatus(transport) {
1680
1698
  }
1681
1699
  catch (error) {
1682
1700
  if (error instanceof GraphIndexAbsentError)
1683
- return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1701
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: `${error.message} 只读核查(不写索引):omc graph status --env <env>(当前配置根);MCP 面工具读的可能是另一配置根,比对 dbId/dbPath。`, nextCommand: "omc graph pull --env <env>" }, transport);
1684
1702
  return emitThrown(error, transport);
1685
1703
  }
1686
1704
  }
@@ -1693,7 +1711,7 @@ function cmdGraphImpact(kind, code, transport) {
1693
1711
  }
1694
1712
  catch (error) {
1695
1713
  if (error instanceof GraphIndexAbsentError)
1696
- return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1714
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: `${error.message} 只读核查(不写索引):omc graph status --env <env>(当前配置根);MCP 面工具读的可能是另一配置根,比对 dbId/dbPath。`, nextCommand: "omc graph pull --env <env>" }, transport);
1697
1715
  return emitThrown(error, transport);
1698
1716
  }
1699
1717
  }
@@ -1704,7 +1722,7 @@ function cmdGraphSearch(term, transport) {
1704
1722
  }
1705
1723
  catch (error) {
1706
1724
  if (error instanceof GraphIndexAbsentError)
1707
- return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1725
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: `${error.message} 只读核查(不写索引):omc graph status --env <env>(当前配置根);MCP 面工具读的可能是另一配置根,比对 dbId/dbPath。`, nextCommand: "omc graph pull --env <env>" }, transport);
1708
1726
  return emitThrown(error, transport);
1709
1727
  }
1710
1728
  }
@@ -2470,7 +2488,7 @@ async function main() {
2470
2488
  pureKeys: (options.keys === true || bareJq) && options.summary !== true, ...(typeof options.keys === "string" ? { keys: options.keys } : {}), full: options.full === true }));
2471
2489
  });
2472
2490
  command.command("bench").option("--live-sample").option("--env <environment>").action(async (options) => { commandExitCode = await cmdBench([...(options.liveSample ? ["--live-sample"] : []), ...(options.env ? ["--env", options.env] : [])]); });
2473
- command.command("residue <prefix>").description("跨面前缀残留扫描(model 全租户/folder/custompage;默认 deep 再查命中模型的 rule/datarule/onlinejs)——收尾「删净」一次证明,clean:true 即零残留。只读零写入;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)); });
2491
+ 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)); });
2474
2492
  const jqOption = () => new Option("--jq [path]", "路径投影(与 call --jq 同 DSL;可逗号多路径;裸 --jq 列顶层键)").argParser((value, previous = []) => [...previous, value]).default([]);
2475
2493
  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 } : {}) })); });
2476
2494
  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) => {
@@ -14,6 +14,12 @@ export function removedProfileKeys(profile) {
14
14
  return REMOVED_PROFILE_KEYS.filter((key) => raw[key] !== undefined);
15
15
  }
16
16
  export class ConfigurationError extends Error {
17
+ policyRefused;
18
+ constructor(message, options = {}) {
19
+ super(message);
20
+ this.name = "ConfigurationError";
21
+ this.policyRefused = options.policyRefused === true;
22
+ }
17
23
  }
18
24
  export function defaultPaths(projectDir = process.cwd()) {
19
25
  const configRoot = process.env.OMC_CONFIG_HOME
@@ -290,12 +296,25 @@ export class ConfigStore {
290
296
  const allowText = allowlist.length === 0
291
297
  ? `.omc-envs=(无或不存在;准入回退到 .omc-env ∪ 全局 default)`
292
298
  : `.omc-envs=${allowlist.join(",")}`;
299
+ const configuredEnvironments = this.readConfig().environments;
300
+ const candidates = Object.entries(configuredEnvironments)
301
+ .filter(([, profile]) => profile.applicationCode === name);
302
+ // If several environment names point at the same application, recommend
303
+ // the one the caller is already working in before falling back to the
304
+ // machine-wide configuration order. This avoids steering an agent from a
305
+ // project-local `local` environment to an unrelated `prod` entry merely
306
+ // because `prod` appears first in omc.config.json.
307
+ const applicationHint = candidates.find(([candidate]) => candidate === bound)?.[0]
308
+ ?? candidates.find(([candidate]) => allowlist.includes(candidate))?.[0]
309
+ ?? candidates.find(([, profile]) => profile.isDefault)?.[0]
310
+ ?? candidates[0]?.[0];
293
311
  throw new ConfigurationError(`跨环境访问被拒绝:环境 "${name}" 不属于本项目(本项目允许:${own.join("、") || "(none)"})。` +
294
312
  `解析到的项目根=${projectRoot};${boundText};${allowText}。` +
295
313
  `准入由「当前工作目录」下的 .omc-envs(白名单)+ .omc-env(默认绑定)共同决定,不是由 --env 参数单独决定;` +
296
314
  `在无声明的目录里 --env local 会落到机器全局 default(常见是 test)。` +
297
315
  `请改到声明了该环境的项目根再跑,或先向用户报告并取得同意后执行 \`omc env allow ${name}\`(写入项目 .omc-envs),` +
298
- `或临时设置 OMC_ALLOW_CROSS_ENV=1 单次放行。`);
316
+ `或临时设置 OMC_ALLOW_CROSS_ENV=1 单次放行。` +
317
+ (applicationHint === undefined ? "" : `\n也许你指的是 --env ${applicationHint}(app=${name})。`), { policyRefused: true });
299
318
  }
300
319
  bindProjectEnvironment(name) {
301
320
  const config = this.readConfig();
@@ -5,7 +5,7 @@ import { assertHtmlRender, executeFormDraftWrite, executeFormPublishWrite, readF
5
5
  import { capabilityArg, environmentArg } from "../args.js";
6
6
  import { writeTool } from "../write.js";
7
7
  import { governedRead } from "../read.js";
8
- import { defineTool } from "../tool-types.js";
8
+ import { defineTool, ToolRefusalError } from "../tool-types.js";
9
9
  import { wireCall } from "../wire.js";
10
10
  import { composeCustomScript, lintOnlineJsSource } from "../online-js-source.js";
11
11
  import { composeLayoutElements } from "../online-js-layout.js";
@@ -51,7 +51,9 @@ function readCustomScriptSource(rawHtml) {
51
51
  // real script when the find falls back to the tag name.
52
52
  const element = elements.find((candidate) => candidate.id === "customScript");
53
53
  const script = typeof element?.innerHTML === "string" && element.innerHTML.trim() !== "" ? element.innerHTML : null;
54
- return { present: script !== null, script, sha256: script !== null ? createHash("sha256").update(script, "utf8").digest("hex") : null, elementCount: elements.length };
54
+ return script !== null
55
+ ? { present: true, script, sha256: createHash("sha256").update(script, "utf8").digest("hex"), elementCount: elements.length }
56
+ : { present: false, script: null, sha256: "", elementCount: elements.length, notCollected: true, notCollectedReason: "customScript 不存在,无法计算脚本哈希" };
55
57
  }
56
58
  export function registerOnlineJsLane(tools) {
57
59
  tools.push(defineTool({
@@ -81,7 +83,7 @@ export function registerOnlineJsLane(tools) {
81
83
  sheetPublished: sheet.published === true,
82
84
  draft: { ...draft, ...(draft.script === null ? { note: "草稿无 customScript 脚本(尚未 compose 或被清空)" } : {}) },
83
85
  published: { ...published, ...(published.script === null ? { note: "发布面无 customScript 脚本" } : {}) },
84
- ...(draft.sha256 !== null && published.sha256 !== null
86
+ ...(draft.sha256 !== "" && published.sha256 !== ""
85
87
  ? { draftPublishedInSync: draft.sha256 === published.sha256 }
86
88
  : {}),
87
89
  hint: "脚本是 innerHTML 原文(含平台 (function(form){…}) 生命周期包装——那就是平台存储形态);改脚本用 onlinejs.compose(默认 INCREMENTAL 并入,replace:true 整段替换);同文件修订直接 replace:true 传完整脚本",
@@ -109,6 +111,7 @@ export function registerOnlineJsLane(tools) {
109
111
  sheetCode: z.string().min(1).optional().describe("表单槽位码;省略时默认 = schemaCode(与 form.draft/form.publish 同口径)"),
110
112
  source: z.string().optional().describe("在线 JS 代码。默认 INCREMENTAL:并入平台 (function(form){…}) 模板的既有脚本,模板与既有注册全部保留;配 replace:true 才是整段替换。脚本不得使用 export/import;事件名必须是平台事件(onLoad/onRendered/onValidate/onPreAction/onCustomAction/onActionDone/onPrintFormDataHandle/onPrintWorkflowLogDataHandle)。与 remove:true 二选一;source 为空字符串也表示移除 customScript"),
111
113
  replace: z.boolean().optional().describe("true = 以 source 整体替换 customScript(含完整 (function(form){…}) 脚本)。默认 false = 增量并入既有脚本,保留平台模板与既有逻辑"),
114
+ confirmReplaceSha256: z.string().regex(/^[a-f0-9]{64}$/i).optional().describe("replace:true 覆盖既有脚本时必须等于当前脚本 SHA-256,防止静默覆盖线上改动"),
112
115
  remove: z.boolean().optional().describe("true = 显式移除 customScript,使 onlinejs.get 返回 present:false;会写入并按 publish 设置发布。与 source 二选一"),
113
116
  publish: z.boolean().optional().describe("默认 true:写草稿并推发布面;false = 只写草稿不推发布面"),
114
117
  summary: z.boolean().optional().describe("true = 只回同步状态(sha/字节数/是否同步/渲染兼容),不回任何 HTML/JS 原文。确认「改动是否生效」用这个;要看原文用 onlinejs.get"),
@@ -126,6 +129,20 @@ export function registerOnlineJsLane(tools) {
126
129
  const current = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode: targetSheetCode } });
127
130
  const existingSheet = (current.data ?? null);
128
131
  const existingRaw = typeof existingSheet?.draftHtmlJson === "string" ? existingSheet.draftHtmlJson : undefined;
132
+ const existingPublishedRaw = typeof existingSheet?.publishedHtmlJson === "string" ? existingSheet.publishedHtmlJson : undefined;
133
+ const existingScriptState = readCustomScriptSource(existingRaw ?? existingPublishedRaw);
134
+ if (args.replace === true && existingScriptState.present && args.confirmReplaceSha256 !== existingScriptState.sha256) {
135
+ throw new ToolRefusalError({
136
+ status: "refused",
137
+ blocker: "onlinejs-replace-confirmation-required",
138
+ schemaCode: args.schemaCode,
139
+ sheetCode: targetSheetCode,
140
+ previousSha256: existingScriptState.sha256,
141
+ ...(args.confirmReplaceSha256 !== undefined ? { providedSha256: args.confirmReplaceSha256 } : {}),
142
+ message: "replace:true 将覆盖已有线上脚本;必须传 confirmReplaceSha256 且与当前脚本 SHA-256 完全一致。未写入任何内容。",
143
+ nextCommand: `omc call onlinejs.get --args '${JSON.stringify({ schemaCode: args.schemaCode, sheetCode: targetSheetCode })}'`,
144
+ });
145
+ }
129
146
  const existingCompat = validateHtmlRender(existingRaw);
130
147
  // A persisted legacy face may contain an object or malformed elements.
131
148
  // Do not pass any of it through: discard the unproven face and rebuild
@@ -203,7 +220,9 @@ export function registerOnlineJsLane(tools) {
203
220
  draftPublishedInSync,
204
221
  publishedElementCount: publishedElements.length,
205
222
  scriptElementPresent: publishedElements.some((element) => element.id === "customScript"),
206
- customScriptMerged: args.replace !== true,
223
+ scriptElementMeaning: "skeleton 空槽不等于存在脚本",
224
+ composeMode: args.replace === true ? "replace" : "incremental",
225
+ ...(removing ? { removed: true } : {}),
207
226
  customScriptSha256,
208
227
  customScriptBytes: Buffer.byteLength(composedScript, "utf8"),
209
228
  composedDraftBytes: Buffer.byteLength(composedDraftHtml, "utf8"),
@@ -230,9 +249,11 @@ export function registerOnlineJsLane(tools) {
230
249
  draftPublishedInSync,
231
250
  publishedElementCount: publishedElements.length,
232
251
  scriptElementPresent: publishedElements.some((element) => element.id === "customScript"),
252
+ scriptElementMeaning: "skeleton 空槽不等于存在脚本",
233
253
  customScript: composedScript,
234
254
  customScriptMatchesPublished: draftPublishedInSync,
235
- customScriptMerged: args.replace !== true,
255
+ composeMode: args.replace === true ? "replace" : "incremental",
256
+ ...(removing ? { removed: true } : {}),
236
257
  htmlRenderCompat,
237
258
  portalVerificationPlan: ["在门户打开该表单", "触发已绑定的在线 JS 事件", "检查事件产生的副作用字段或校验提示"],
238
259
  },
@@ -38,7 +38,7 @@ export function dataSubmitDescription() {
38
38
  const wire = RUNTIME_DATA_WIRE;
39
39
  return describeTool(`M2 runtime data SUBMIT (runtime/form/submit, WorkItemSubmitVO).`, [
40
40
  "Distinct from data.save: data.save persists as DRAFT; data.submit advances the record to COMPLETED (no workflow) or drives the workflow instance.",
41
- "**审批提交(workItemId+agree)时 `formData` 也必填,且其值会写回业务对象——勿把期望结果(如审批级别)预写进 formData,否则规则回写的真伪无法区分(2026-09-22 实测返工案例)**;只传身份参数会被拒 formData: Required。",
41
+ "**审批提交(带 workItemId)可省略 `formData`**:平台使用已存业务数据,且不要携带子表数组;新建记录(无 bizObjectId/workItemId)才必须传 formData。此前把两种场景混成 Required 是错误提示(2026-11-07 r40)。",
42
42
  `就地语义:${wire.submitInPlace};无流程 readback 必须确认 objectId 与传入 bizObjectId 相同;无 bizObjectId 的 workflowCode 新建单 → PROCESSING + workflowInstanceId,无流程 → 该行 COMPLETED。`,
43
43
  `既有草稿发起流程的唯一支持形状是两段式:先 data.save 带 workflowCode 暂存(${wire.workflowDraftSave}),再以返回的 workItemId 提交(issue#11 P6:带流程模型提交**必须**带 bizObjectId+workflowInstanceId+workItemId 三元——只带 bizObjectId 会答「流程实例无待办」、只缺 workItemId 会 50000;完整形状见 omc describe data.submit --example workflow-backed-data-submit,待办查询用 workitem.list)。`,
44
44
  `仅无 workflowCode/workItemId/workflowInstanceId 且无 bizObjectId 时保留 save→submit 便捷链(新建记录);只要存在 workItemId 或 workflowInstanceId 就 fail-closed 拒绝(blocker submit-biz-object-id-required),零 runtime/form/submit wire。`,
@@ -33,8 +33,9 @@ function dataListItemPreview(items) {
33
33
  return { ids, itemsPreview };
34
34
  }
35
35
  /**
36
- * r29b F8:`data.list` 的大结果被外置后 `items:[]`,调用方连 id 都拿不到
37
- * (3 行的模型也会这样——于是「没数据」与「被外置」看起来一样)。
36
+ * r40a B:`data.list` 的大结果被外置后不能回 `items:[]`,否则调用方会把
37
+ * 「有数据但在 artifact」误读成「空结果」。保留一个明确的 items 外置指针,
38
+ * 让通用读门不会再补回空数组占位。
38
39
  *
39
40
  * **只在确实要丢弃 items 时才补 `ids`/`itemsPreview`**:这段只在 governedRead
40
41
  * 判定 `large`(>2048B)后才会被调用。早期版本把 ids/preview 无条件塞进 handler
@@ -55,8 +56,14 @@ function dataListCompactProjection(result) {
55
56
  compact.itemsPreview = preview.ids.slice(0, 3).map((id) => ({ id }));
56
57
  if (value.length > 0)
57
58
  compact.listArtifactHint =
58
- "items 已外置(truncatedKeys 含 items):完整行见 artifact(detailArtifactId / fetch)。同信封三分法:ids=本页全部 id;itemsPreview=ids 前 3 个的引用(仅 {id},不是第二份行);items=[] 表示正文在 artifact。";
59
- compact.itemsExternalized = true;
59
+ "items 已外置:items 是指针而非空结果;完整行见 artifact(detailArtifactId / fetch)。ids=本页全部 id;itemsPreview=ids 前 3 个的引用(仅 {id},不是第二份行)。";
60
+ // The generic read gate adds [] for dropped array keys. Keep an explicit
61
+ // pointer under the original key so it cannot manufacture that false
62
+ // empty-result shape. The full rows remain in detailArtifactId.
63
+ compact.items = { externalized: true, count: value.length };
64
+ compact.truncatedKeys = ["items"];
65
+ compact.truncated = true;
66
+ compact.itemsExternalized = { count: value.length };
60
67
  continue;
61
68
  }
62
69
  // 这两个键由本函数按 items 现算,handler 不再重复内联。
@@ -1867,9 +1874,9 @@ export function registerRuntimeDataLane(tools) {
1867
1874
  * gone, not that the model is clean — a submit can leave a separate draft row
1868
1875
  * behind. After a delete, read the model's residue so "cleaned up" is a
1869
1876
  * model-level fact (`total:0`). Best-effort: a query failure never fails the
1870
- * delete, but it IS surfaced under `preExistingRows.error`.
1877
+ * delete, but it IS surfaced as an explicit readback error.
1871
1878
  */
1872
- async function preExistingRows(session, schemaCode, sheetCode) {
1879
+ async function remainingRows(session, schemaCode, sheetCode) {
1873
1880
  try {
1874
1881
  const result = await wireCall(session, {
1875
1882
  path: ENDPOINTS.runtimeQueryList,
@@ -1877,12 +1884,12 @@ export function registerRuntimeDataLane(tools) {
1877
1884
  body: { schemaCode, queryCode: schemaCode, filters: [], mobile: false, page: 0, size: 1, queryVersion: 1, showTotal: true },
1878
1885
  });
1879
1886
  const page = extractBizObjectPage(result.data);
1880
- return { preExistingRows: { total: page.total, clean: page.total === 0, sheetCode, note: "含历史既有数据,非本次创建" } };
1887
+ return { remainingRows: page.total, clean: page.total === 0, sheetCode, cleanNote: "clean 表示删除该行后该表是否已为空" };
1881
1888
  }
1882
1889
  catch (error) {
1883
1890
  if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.emptyModelErrcode)
1884
- return { preExistingRows: { total: 0, clean: true, sheetCode, note: "含历史既有数据,非本次创建" } };
1885
- return { preExistingRows: { total: null, clean: null, sheetCode, note: "含历史既有数据,非本次创建", error: error instanceof Error ? error.message : String(error) } };
1891
+ return { remainingRows: 0, clean: true, sheetCode, cleanNote: "clean 表示删除该行后该表是否已为空" };
1892
+ return { remainingRows: null, clean: null, sheetCode, cleanNote: "clean 表示删除该行后该表是否已为空", error: error instanceof Error ? error.message : String(error) };
1886
1893
  }
1887
1894
  }
1888
1895
  writeTool({
@@ -1981,13 +1988,14 @@ export function registerRuntimeDataLane(tools) {
1981
1988
  noop: true,
1982
1989
  recoveryHint: "若另一进程刚刚删除,这是正常并发结果(already-absent),不是 id 拿错。否则:真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏。",
1983
1990
  thisRow: { absent: true, deleted: false },
1984
- ...(await preExistingRows(session, args.schemaCode, sheetCode)),
1991
+ thisRowDeleted: true,
1992
+ ...(await remainingRows(session, args.schemaCode, sheetCode)),
1985
1993
  },
1986
1994
  };
1987
1995
  }
1988
1996
  return {
1989
1997
  performed: { errcode: one.errcode, deleted: one.deleted === true, noop: false, sheetCode, resolvedFrom },
1990
- readback: { absent: one.absent, deleted: one.deleted === true, thisRow: { absent: one.absent, deleted: one.deleted === true }, ...(await preExistingRows(session, args.schemaCode, sheetCode)) },
1998
+ readback: { absent: one.absent, deleted: one.deleted === true, thisRowDeleted: one.deleted === true, thisRow: { absent: one.absent, deleted: one.deleted === true }, ...(await remainingRows(session, args.schemaCode, sheetCode)) },
1991
1999
  };
1992
2000
  }
1993
2001
  const deleted = [];
@@ -1995,7 +2003,7 @@ export function registerRuntimeDataLane(tools) {
1995
2003
  deleted.push(await deleteOne(id));
1996
2004
  // `deleted`/`count` ride `readback` — the write envelope surfaces only
1997
2005
  // performed/readback inline (extra top-level keys are artifact-only).
1998
- return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await preExistingRows(session, args.schemaCode, sheetCode)) } };
2006
+ return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await remainingRows(session, args.schemaCode, sheetCode)) } };
1999
2007
  },
2000
2008
  });
2001
2009
  writeTool({
@@ -2007,7 +2015,7 @@ export function registerRuntimeDataLane(tools) {
2007
2015
  inputSchema: {
2008
2016
  schemaCode: z.string().min(1),
2009
2017
  sheetCode: z.string().optional(),
2010
- formData: runtimeFormDataArg,
2018
+ formData: runtimeFormDataArg.optional().describe("business-object data keyed by field code;新建记录必填,审批提交(带 workItemId)可省略——平台使用已存业务数据"),
2011
2019
  workflowCode: z.string().optional(),
2012
2020
  workflowInstanceId: z.string().optional(),
2013
2021
  workItemId: z.string().optional(),
@@ -2077,7 +2085,16 @@ export function registerRuntimeDataLane(tools) {
2077
2085
  });
2078
2086
  }
2079
2087
  const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
2080
- const stampedSubmit = await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: targetObjectId !== null });
2088
+ if (!hasWorkItem && args.formData === undefined) {
2089
+ throw new ToolInputError({
2090
+ status: "error",
2091
+ blocker: "tool-input-invalid",
2092
+ tool: "data.submit",
2093
+ message: "新建提交缺少 formData;审批提交可省略 formData(子表数组不要带),新建记录才必须传 formData。",
2094
+ nextCommand: "omc call data.submit --args '{\"schemaCode\":\"<schemaCode>\",\"formData\":{...}}'",
2095
+ });
2096
+ }
2097
+ const stampedSubmit = await prepareRuntimeFormData(session, args.schemaCode, (args.formData ?? {}), { isUpdate: targetObjectId !== null });
2081
2098
  const formData = stampedSubmit.formData;
2082
2099
  // r5-asset 2026-10-20: the platform APPENDS child rows carried in an
2083
2100
  // approval submit's formData (no upsert by row id, no error). Live: an
@@ -2089,7 +2106,7 @@ export function registerRuntimeDataLane(tools) {
2089
2106
  // {bizObject:{data:{...}}}, and a top-level-only check let those through
2090
2107
  // (the platform appends whatever it finds). The child codes come from the
2091
2108
  // model's own field list.
2092
- const carried = collectChildArrayPaths(args.formData, new Set(stampedSubmit.childCodes));
2109
+ const carried = collectChildArrayPaths((args.formData ?? {}), new Set(stampedSubmit.childCodes));
2093
2110
  if (carried.length > 0)
2094
2111
  throw new ToolRefusalError({
2095
2112
  status: "refused",
@@ -2124,7 +2141,7 @@ export function registerRuntimeDataLane(tools) {
2124
2141
  `确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
2125
2142
  });
2126
2143
  }
2127
- const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData).catch(() => ({ requiredFields: [], missingRequired: [] }));
2144
+ const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData ?? {}).catch(() => ({ requiredFields: [], missingRequired: [] }));
2128
2145
  // Directive: a workflow-backed model's submit must START the workflow, not
2129
2146
  // merely save a sheet row. When the caller names no workflow params, detect
2130
2147
  // the model's workflow and start it (formType=1 + workflowCode); a model
@@ -2178,7 +2195,7 @@ export function registerRuntimeDataLane(tools) {
2178
2195
  bizObject: {
2179
2196
  schemaCode: args.schemaCode,
2180
2197
  sheetCode,
2181
- data: args.formData,
2198
+ data: args.formData ?? {},
2182
2199
  },
2183
2200
  agree: args.agree !== false,
2184
2201
  };
@@ -3314,12 +3314,20 @@ export function registerWorkflowLane(tools) {
3314
3314
  const purposeHint = args.workflowInstanceId === undefined && args.workflowCode === undefined
3315
3315
  ? "本调用用于发现待办并取 workItemId 做审批;只想看流程走到哪,用 workflow.instanceActivity;提交/审批后优先复用 data.submit 的 readback.nextSubmit,不要重复查询。"
3316
3316
  : undefined;
3317
+ // r40a-E(2026-11-07,E2E F29):按实例查且本人口径 total:0 时,与「流程没生成」
3318
+ // 不可区分——探针差点误判「审批没生成」。存在范围外待办时显式说明口径差。
3319
+ const scopedToInstance = args.workflowInstanceId !== undefined && args.participant === undefined;
3320
+ const unfiltered = page.unfilteredTotal;
3321
+ const othersScopeNote = scopedToInstance && page.total === 0 && typeof unfiltered === "number" && unfiltered > 0
3322
+ ? `total:0 是当前登录人口径——查询范围(unfilteredTotal=${unfiltered})内存在他人的待办。若该实例应有待办:待办按持有人过滤,用 participant:<持有人id> 复查(持有人见 data.submit 回执 flowWarning);不要据此判定「流程未生成」。`
3323
+ : undefined;
3317
3324
  return {
3318
3325
  total: page.total,
3319
3326
  // The JPA-spec path reports no page count; only emit it when known.
3320
3327
  ...("totalPages" in page && page.totalPages !== undefined ? { totalPages: page.totalPages } : {}),
3321
3328
  itemsCount: items.length,
3322
3329
  ...(purposeHint ? { purposeHint } : {}),
3330
+ ...(othersScopeNote ? { scopeNote: othersScopeNote } : {}),
3323
3331
  ...(args.participant !== undefined ? { participant: args.participant, state: args.state ?? "PENDING", source: "list_workitems" } : {}),
3324
3332
  ...("unfilteredTotal" in page && page.unfilteredTotal !== undefined ? { unfilteredTotal: page.unfilteredTotal } : {}),
3325
3333
  items,
package/dist/mcp-graph.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
+ import { DatabaseSync } from "node:sqlite";
2
3
  import { existsSync } from "node:fs";
3
4
  import { resolve } from "node:path";
4
5
  import { createInterface } from "node:readline";
@@ -60,7 +61,7 @@ const EXPLORE_TOOL = {
60
61
  "一次查全一个目标的引用与影响面(本地索引,毫秒级)。",
61
62
  "输入 query 可为业务名(实体 name+code 子串)或规则 code+name 词首;也可直接给字段/模型/服务 code。kind 可选 field|model|service,不给则三种都查。",
62
63
  "返回 {index(freshness), references:{kind:[{schemaCode,ruleCode,nodeCode,nodeType,via}]}, impact:{kind:totals}, search:{matches:{model,field,service},rules}}。",
63
- "references/impact 支持 offset+limit 分页;truncated 时按返回的 nextOffset 继续调用。",
64
+ "references/impact 支持 offset+limit 分页;truncated 时按返回的 nextOffset 继续调用。实体分页结果带 nextCall;分页不会改变 resolved。字段同名时请用 schemaCode 消歧。",
64
65
  "改/删/重命名任何字段、模型、服务之前必查;找\"关于 X 的规则\"拿 code 也走这里。",
65
66
  "索引不含规则条件叶子与流程绑定;freshness=partial 时命中可能不完整(答案里有 gapWarning)。",
66
67
  ].join(" "),
@@ -69,6 +70,7 @@ const EXPLORE_TOOL = {
69
70
  properties: {
70
71
  query: { type: "string", description: "业务名(实体 name+code 子串)、字段/模型/服务 code,或规则检索词(规则 code+name 词首匹配)" },
71
72
  kind: { type: "string", enum: [...KINDS], description: "限定一种引用种类;省略则 field/model/service 全查" },
73
+ schemaCode: { type: "string", description: "限定字段所属模型;也可用于校验模型。" },
72
74
  env: { type: "string", description: "期望的环境名;与索引所属环境不一致时会拒绝(防跨环境误答)" },
73
75
  offset: { type: "integer", minimum: 0, description: "引用/实体结果起始偏移;truncated 时使用 nextOffset" },
74
76
  limit: { type: "integer", minimum: 1, maximum: MATCH_CAP, description: "每类结果最多返回多少条(默认 50)" },
@@ -78,7 +80,7 @@ const EXPLORE_TOOL = {
78
80
  };
79
81
  const STATUS_TOOL = {
80
82
  name: "omc_graph_status",
81
- description: "索引完整性与新鲜度:environmentName、freshness(complete|partial)、模型/规则计数、pulledAtIso、ageMinutes、staleAfterMinutes、dbPath、coverage.applicationCodes、gaps。超过阈值会给 warning;回答前先看一眼,索引缺失会给出 pull 指引。",
83
+ description: "索引完整性与新鲜度(所有身份与新鲜度字段同层):environmentName、freshness(complete|partial)、模型/规则计数、pulledAtIso、ageMinutes、staleAfterMinutes、dbId、dbPath、coverage、gaps。超过阈值会给 warning;索引缺失会给出 pull 指引。",
82
84
  inputSchema: { type: "object", properties: {} },
83
85
  };
84
86
  export function graphToolDefinitions() {
@@ -90,6 +92,57 @@ function toolResult(payload, isError = false) {
90
92
  function toolError(message) {
91
93
  return toolResult({ status: "error", message }, true);
92
94
  }
95
+ function withMatchedOn(row, query) {
96
+ const name = row.name ?? "";
97
+ const code = row.code ?? row.schemaCode ?? "";
98
+ const on = name.includes(query) ? "name" : "code";
99
+ const value = on === "name" ? name : code;
100
+ const at = value.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
101
+ if (at < 0)
102
+ return { ...row, matchedOn: { on, fragment: query } };
103
+ let start = at;
104
+ let end = at + query.length;
105
+ while (start > 0 && /[\p{L}\p{N}]/u.test(value[start - 1]))
106
+ start--;
107
+ while (end < value.length && /[\p{L}\p{N}]/u.test(value[end]))
108
+ end++;
109
+ return { ...row, matchedOn: { on, fragment: value.slice(start, end) } };
110
+ }
111
+ function levenshtein(a, b) {
112
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
113
+ for (let i = 1; i <= a.length; i++) {
114
+ let corner = prev[0];
115
+ prev[0] = i;
116
+ for (let j = 1; j <= b.length; j++) {
117
+ const old = prev[j];
118
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, corner + (a[i - 1] === b[j - 1] ? 0 : 1));
119
+ corner = old;
120
+ }
121
+ }
122
+ return prev[b.length];
123
+ }
124
+ function entityInventory() {
125
+ const db = new DatabaseSync(graphDbPath(), { readOnly: true });
126
+ try {
127
+ return [
128
+ ...db.prepare("SELECT schema_code, name FROM models").all().map((r) => ({ kind: "model", schemaCode: r.schema_code, code: r.schema_code, name: r.name ?? "" })),
129
+ ...db.prepare("SELECT schema_code, code, name FROM fields").all().map((r) => ({ kind: "field", schemaCode: r.schema_code, code: r.code, name: r.name ?? "" })),
130
+ ...db.prepare("SELECT code, name FROM services").all().map((r) => ({ kind: "service", code: r.code, name: r.name ?? "" })),
131
+ ];
132
+ }
133
+ finally {
134
+ db.close();
135
+ }
136
+ }
137
+ function suggestions(query) {
138
+ return entityInventory().map((row) => {
139
+ const q = query.toLocaleLowerCase();
140
+ const values = [row.code, row.name].map((v) => v.toLocaleLowerCase());
141
+ const prefix = values.some((v) => v.startsWith(q)) ? 0 : values.some((v) => v.includes(q)) ? 1 : 2;
142
+ const distance = Math.min(...values.map((v) => levenshtein(q, v)));
143
+ return { kind: row.kind, code: row.code, name: row.name, score: prefix * 1000 + distance };
144
+ }).sort((a, b) => a.score - b.score || a.code.localeCompare(b.code)).slice(0, 3);
145
+ }
93
146
  function cap(rows, offset = 0, limit = MATCH_CAP) {
94
147
  const page = rows.slice(offset, offset + limit);
95
148
  return { rows: page, truncated: offset + page.length < rows.length, offset, limit, ...(offset + page.length < rows.length ? { nextOffset: offset + page.length } : {}), total: rows.length };
@@ -100,7 +153,9 @@ export async function executeGraphTool(name, rawArgs) {
100
153
  try {
101
154
  if (name === STATUS_TOOL.name) {
102
155
  const status = readGraphStatus();
103
- return toolResult({ status: "ok", ...status });
156
+ // Keep a tiny compatibility locator for older MCP clients; all identity,
157
+ // freshness, coverage and gaps fields live at the top level.
158
+ return toolResult({ status: "ok", ...status, ...status.index, index: { environmentName: status.index.environmentName } });
104
159
  }
105
160
  if (name === EXPLORE_TOOL.name) {
106
161
  const query = typeof args.query === "string" ? args.query.trim() : "";
@@ -109,6 +164,9 @@ export async function executeGraphTool(name, rawArgs) {
109
164
  const kind = args.kind === undefined ? undefined : String(args.kind);
110
165
  if (kind !== undefined && !KINDS.includes(kind))
111
166
  return toolError(`kind 必须是 ${KINDS.join("|")} 之一`);
167
+ const schemaCode = typeof args.schemaCode === "string" && args.schemaCode.trim() ? args.schemaCode.trim() : undefined;
168
+ if (schemaCode && kind !== "field" && kind !== "model")
169
+ return toolError("schemaCode 仅适用于 kind=field|model");
112
170
  const status = readGraphStatus();
113
171
  const index = status.index;
114
172
  if (typeof args.env === "string" && args.env.trim() && args.env.trim() !== index.environmentName) {
@@ -119,14 +177,32 @@ export async function executeGraphTool(name, rawArgs) {
119
177
  const limit = typeof args.limit === "number" && Number.isSafeInteger(args.limit) && args.limit > 0 && args.limit <= MATCH_CAP ? args.limit : MATCH_CAP;
120
178
  const references = {};
121
179
  const impact = {};
180
+ // Resolve from an unpaged search. The result page is presentation only;
181
+ // otherwise offset=2 can silently change the subject being inspected.
182
+ const search = searchGraphRules(query, limit, offset);
183
+ const resolvedSearch = searchGraphRules(query, MATCH_CAP, 0);
184
+ const loweredQuery = query.toLocaleLowerCase();
185
+ const allCandidates = entityInventory().filter((r) => (r.code.toLocaleLowerCase().includes(loweredQuery) || r.name.toLocaleLowerCase().includes(loweredQuery)) &&
186
+ (!schemaCode || (r.kind !== "service" && r.schemaCode === schemaCode)));
187
+ const typedCandidates = kind ? allCandidates.filter((r) => r.kind === kind) : allCandidates;
188
+ const fieldCandidates = kind === "field" ? typedCandidates : allCandidates.filter((r) => r.kind === "field");
189
+ const exactSame = fieldCandidates.filter((r) => r.code === query);
190
+ const ambiguous = kind === "field" && !schemaCode && exactSame.length > 1;
191
+ const resolvedEntity = ambiguous ? undefined : typedCandidates[0] ?? (kind === undefined ? allCandidates[0] : undefined);
192
+ const subjectByName = query !== (resolvedEntity?.code ?? "") && resolvedEntity;
193
+ const subjects = resolvedEntity
194
+ ? [{ kind: resolvedEntity.kind, code: resolvedEntity.kind === "model" ? (resolvedEntity.schemaCode ?? resolvedEntity.code) : resolvedEntity.code }]
195
+ : [];
122
196
  for (const one of kinds) {
123
- const ref = queryGraphReferences(one, query);
124
- const capped = cap(ref.matches, offset, limit);
197
+ const subject = subjects.find((s) => s.kind === one);
198
+ const ref = queryGraphReferences(one, subject?.code ?? query);
199
+ const scopedMatches = subject && one === "field" && schemaCode ? ref.matches.filter((row) => row.schemaCode === schemaCode) : ref.matches;
200
+ const capped = cap(scopedMatches, offset, limit);
125
201
  // r35a-F4c(真机 2026-11-06):**MCP 面是第三个消费者**——CLI 的 references
126
202
  // 与 impact 都带 coverage 了,这里若不显式透传,MCP 用户看到的仍是
127
203
  // 「matches:[] + 一句旧 note」,照样会把「表单没扫」误读成「没人引用」。
128
204
  references[one] = {
129
- count: ref.matches.length,
205
+ count: scopedMatches.length,
130
206
  rows: capped.rows,
131
207
  offset: capped.offset,
132
208
  limit: capped.limit,
@@ -134,35 +210,49 @@ export async function executeGraphTool(name, rawArgs) {
134
210
  ...(ref.coverage ? { coverage: ref.coverage } : {}),
135
211
  ...(ref.gapWarning ? { gapWarning: ref.gapWarning } : {}),
136
212
  };
137
- if (ref.matches.length > 0 || kind !== undefined) {
138
- const imp = impactGraph(one, query);
213
+ if (scopedMatches.length > 0 || kind !== undefined || subject !== undefined) {
214
+ const imp = impactGraph(one, subject?.code ?? query);
215
+ const scopedRules = subject && one === "field" && schemaCode ? imp.rules.filter((row) => row.schemaCode === schemaCode) : imp.rules;
139
216
  impact[one] = {
140
- totals: imp.totals,
141
- rules: cap(imp.rules, offset, limit),
217
+ totals: scopedRules.length === imp.rules.length ? imp.totals : { ...imp.totals, rules: scopedRules.length },
218
+ rules: cap(scopedRules, offset, limit),
142
219
  datarules: cap(imp.datarules, offset, limit),
143
220
  listviews: cap(imp.listviews, offset, limit),
221
+ ...(one === "model" ? {
222
+ fieldsReferenced: [...imp.rules.flatMap((r) => r.touches.field.map((code) => ({ code, ruleCode: r.ruleCode }))).reduce((m, x) => {
223
+ const item = m.get(x.code) ?? { code: x.code, touchedByRules: [] };
224
+ if (!item.touchedByRules.includes(x.ruleCode))
225
+ item.touchedByRules.push(x.ruleCode);
226
+ m.set(x.code, item);
227
+ return m;
228
+ }, new Map()).values()],
229
+ } : {}),
144
230
  ...(imp.coverage ? { coverage: imp.coverage } : {}),
145
231
  };
146
232
  }
147
233
  }
148
- const search = searchGraphRules(query, limit, offset);
149
- const resolvedEntity = search.matches.model.items[0] ? { kind: "model", schemaCode: search.matches.model.items[0].schemaCode, code: search.matches.model.items[0].schemaCode } :
150
- search.matches.field.items[0] ? { kind: "field", schemaCode: search.matches.field.items[0].schemaCode, code: search.matches.field.items[0].code } :
151
- search.matches.service.items[0] ? { kind: "service", schemaCode: "", code: search.matches.service.items[0].code } :
152
- search.rules[0] ? { kind: "rule", schemaCode: search.rules[0].schemaCode, code: search.rules[0].ruleCode } : undefined;
234
+ const decorate = (row) => withMatchedOn({ ...row, ...(row.schemaCode && row.code === undefined ? { code: row.schemaCode } : {}) }, query);
235
+ const decoratedMatches = { ...search.matches, model: { ...search.matches.model, items: search.matches.model.items.map(decorate) }, field: { ...search.matches.field, items: search.matches.field.items.map(decorate) }, service: { ...search.matches.service, items: search.matches.service.items.map(decorate) } };
236
+ const noEntityHit = search.matches.model.total === 0 && search.matches.field.total === 0 && search.matches.service.total === 0;
237
+ const composite = /\s|\+|,|\b(?:AND|OR)\b/i.test(query);
153
238
  const searchNote = "实体检索:业务名/实体 name+code 使用子串匹配。\n规则检索:规则 code+name 使用词首匹配(中文子串不命中)。";
154
239
  const entityTruncated = Object.values(search.matches).some((match) => match.truncated === true);
155
- const paginationNote = entityTruncated ? `\n结果已截断:共 N 条时请使用对应 match 的 nextOffset,并在下一次调用传 offset=nextOffset、limit=${limit} 取下一段。` : "";
240
+ const nextCall = entityTruncated ? Object.fromEntries(Object.entries(search.matches).filter(([, m]) => m.truncated).map(([k, m]) => [k, { query, kind: k, offset: m.nextOffset, limit }])) : undefined;
241
+ const paginationNote = entityTruncated ? `\n结果已截断:请使用 nextCall 中对应调用继续。` : "";
156
242
  return toolResult({
157
243
  status: "ok",
158
244
  query,
159
245
  found: resolvedEntity !== undefined,
160
- ...(resolvedEntity ? { resolved: resolvedEntity } : {}),
246
+ ...(resolvedEntity ? { resolved: { kind: resolvedEntity.kind, schemaCode: "schemaCode" in resolvedEntity ? resolvedEntity.schemaCode : "", code: resolvedEntity.code, matchedOn: { on: resolvedEntity.name.includes(query) ? "name" : "code", fragment: query } } } : {}),
247
+ ...(ambiguous ? { ambiguous: true, candidates: exactSame.map((r) => ({ schemaCode: r.schemaCode, code: r.code, name: r.name })) } : {}),
161
248
  ...(kind !== undefined ? { kind } : {}),
162
249
  index,
163
250
  references,
164
251
  impact,
165
- search: { count: search.rules.length, rules: search.rules, matches: search.matches, coverage: search.coverage },
252
+ search: { count: search.rules.length, rules: search.rules, matches: decoratedMatches, coverage: search.coverage },
253
+ ...(nextCall ? { nextCall } : {}),
254
+ ...(noEntityHit && composite ? { unsupportedQuerySyntax: true, hint: "组合检索暂不支持,请拆词单查后求交" } : {}),
255
+ ...(noEntityHit ? { suggestions: suggestions(query), hint: "可尝试 suggestions 中的实体 code/name;引用明细请使用匹配 code 查 references/impact" } : {}),
166
256
  searchNote: `${searchNote}${paginationNote}`,
167
257
  ...(search.note ? { searchNote: `${searchNote}\n${search.note}` } : {}),
168
258
  // 旧句「索引不含规则条件叶子与流程绑定」漏了表单与流程权限(r35a-F4 实证
@@ -187,6 +187,14 @@ const CONTEXTUAL_KEY_GUIDANCE = Object.freeze({
187
187
  precision: "precision→options.scale(数值小数位,不是顶层 precision)",
188
188
  }),
189
189
  });
190
+ // A key can be valid for a sibling tool while still being invalid here. That
191
+ // is a capability-routing problem, not a spelling problem, so do not present
192
+ // the misleading "no close key" sentence. Keep this explicit and narrow: an
193
+ // arbitrary key must continue to use the ordinary closest-key hint.
194
+ const SIBLING_TOOL_KEY_HINTS = Object.freeze({
195
+ "app.update": Object.freeze({ name: '"name" 是 app.create 的入参;app.update 不收它' }),
196
+ "model.create": Object.freeze({ fields: '"fields" 是 configure_model 的入参;model.create 不收它' }),
197
+ });
190
198
  function closestSchemaKey(key, schemaKeys) {
191
199
  if (schemaKeys.has(key))
192
200
  return key;
@@ -249,6 +257,9 @@ export function parseToolInput(tool, input) {
249
257
  const guidance = TWO_STEP_GUIDANCE[tool.name]?.[key] ?? CONTEXTUAL_KEY_GUIDANCE[tool.name]?.[key];
250
258
  if (guidance !== undefined)
251
259
  return guidance;
260
+ const sibling = SIBLING_TOOL_KEY_HINTS[tool.name]?.[key];
261
+ if (sibling !== undefined)
262
+ return sibling;
252
263
  const semantic = KEY_SEMANTIC_HINTS[key];
253
264
  const contextual = contextualClosestKey(tool.name, key, schemaKeys);
254
265
  const closest = closestSchemaKey(key, schemaKeys);
@@ -259,14 +270,15 @@ export function parseToolInput(tool, input) {
259
270
  // 未知键的下一步应指向**正确的那个参数**,不是整份 describe(4–17 KB)。
260
271
  // 有近邻时 `--param <nearKey>` 是 150–900 B,且直接回答「本来想写哪个键」。
261
272
  // 语境建议优先于编辑距离(r34a-F18:data.delete 的 id 指 requestId 是噪声)。
273
+ const siblingHints = unknown.map((key) => SIBLING_TOOL_KEY_HINTS[tool.name]?.[key]).filter((hint) => typeof hint === "string");
262
274
  const nearestKey = contextualClosestKey(tool.name, unknown[0], schemaKeys) ?? closestSchemaKey(unknown[0], schemaKeys);
263
275
  const paramCommand = nearestKey !== undefined ? `omc describe ${tool.name} --param ${nearestKey}` : `omc describe ${tool.name}`;
264
276
  throw new ToolInputError({
265
277
  status: "error",
266
278
  blocker: "tool-input-unknown-keys",
267
279
  tool: tool.name,
268
- message: `写面工具不接受未知键(会被静默丢弃):${unknown.join("、")}。${twoStep.length > 0 ? twoStep.join(" ") : `${guidanceLines.length > 0 ? guidanceLines.join(";") : `最接近的合法键:${suggestions.join(";")}`}。参数表:${paramCommand}`}`,
269
- ...(twoStep.length > 0 || guidanceLines.length > 0 ? { nextCommand: `omc describe ${tool.name}` } : { nextCommand: paramCommand }),
280
+ message: `写面工具不接受未知键(会被静默丢弃):${unknown.join("、")}。${twoStep.length > 0 ? twoStep.join(" ") : siblingHints.length > 0 ? siblingHints.join(";") : `${guidanceLines.length > 0 ? guidanceLines.join(";") : `最接近的合法键:${suggestions.join(";")}`}。参数表:${paramCommand}`}`,
281
+ ...(twoStep.length > 0 || guidanceLines.length > 0 || siblingHints.length > 0 ? { nextCommand: `omc describe ${tool.name}` } : { nextCommand: paramCommand }),
270
282
  issues: unknown.map((key) => ({ path: [key], code: "unrecognized_key", message: issueHintFor(key) })),
271
283
  });
272
284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.6.19",
3
+ "version": "0.6.21",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "commander": "14.0.3",
58
- "omc-core": "0.6.19",
58
+ "omc-core": "0.6.21",
59
59
  "proper-lockfile": "4.1.2",
60
60
  "write-file-atomic": "6.0.0",
61
61
  "zod": "^3.23.8",
@@ -73,6 +73,6 @@
73
73
  "directory": "packages/omc-cli"
74
74
  },
75
75
  "optionalDependencies": {
76
- "@stalfh233/omc-fake-platform": "0.6.19"
76
+ "@stalfh233/omc-fake-platform": "0.6.21"
77
77
  }
78
78
  }
@@ -117,6 +117,7 @@ only the section for the capability you need —
117
117
  - **按钮脚本(JS)读写**:`listview.get --buttonCode <code>`(或 `--buttons`)直接回 `scriptData`/`scriptBytes`/`scriptSha256`,不用翻 artifact;写用 `listview.configure --buttonScripts [{code,script}]` 只改脚本(保留其它字段),或在 `buttons[]` 里用 `script` 简写(= `actionConfig.scriptData`)。
118
118
  - **recipe**:`omc describe listview.configure --example configure-default-list`
119
119
  - **边界 / 陷阱**:默认视图已存在,`listview.configure` 省略 code 即配置默认视图。**按字段分组(queryLayer)未取证**——别猜写。
120
+ - **行级条件标红当前不支持**:`rowColorSetting` 在 8.6.26 没有平台类映射,配置会被拒绝。替代方案是增加/展示状态字段,再用视图筛选或分组表达重点记录;不要把列表行颜色当作已覆盖能力承诺。
120
121
 
121
122
  ## 字段级规则 `datarule`
122
123
 
@@ -144,10 +144,9 @@ you can wait for.
144
144
  范围以任务书为界,任务书之外的增删改仍需新草稿。指纹重核与写后读回照旧。
145
145
  此豁免**要求任务书显式声明预授权**;agent 不得自行把交互会话当成自治上下文。
146
146
 
147
- ## 7. Cleanup
147
+ ## 7. Cleanup / 非交互 run
148
148
 
149
- 清理授权捆在创建授权里:创建草稿写明「验收后删除这些」,确认即一并授权。
150
- **清单外的对象仍要单独出草稿。** 清理只碰自有 `omc_mcp_*` 夹具。
149
+ 清理授权捆在创建授权里:创建草稿写明「验收后删除这些」,确认即一并授权;**清单外对象仍单独出草稿**;清理只碰自有 `omc_mcp_*` 夹具。非交互 run 无 question 通道时按上文「任务书即草稿即批准」执行,另加两条:推定内容逐项写入草稿 `assumptions[]`;**超出任务书范围的写入不能推定**——停下,收口报告标红等新授权。
151
150
 
152
151
  ## 8. Canonical configuration order(规范配置顺序)
153
152
 
@@ -76,6 +76,8 @@ Terminology for the `omc` configuration CLI. Built on CloudPivot 8.6 (anchor
76
76
  tools `data.save` / `data.load` / `data.submit` / `data.update` / `data.list`).
77
77
  `data.load` is the authoritative custom-field readback; list projection
78
78
  is a pinned boundary (custom fields keys-present-values-null).
79
+ - **记录状态 `sequenceStatus`** — 系统流转态枚举:`DRAFT`(草稿)、`PROCESSING`(流程处理中)、`COMPLETED`(已完成/提交态)。判态用 `data.load`,不要用 `data.list` 的入口统计;`data.list` 的 `draftPolicy` 只影响列表计入口径。无流程模型提交用 `data.submit`;有流程模型在 workitem 审批通过后才进入 `COMPLETED`。它不是业务状态字段,业务状态应另建字段并由规则维护。
80
+ - **业务状态** — 平台无内置业务状态字段;需求里的「状态改成大额维修」要显式建业务字段(如 `repairStatus`)+ `rule-when --then 'set:<状态字段>=<值>'` 维护;不要拿 `sequenceStatus` 承载业务枚举。
79
81
  - **B2 service (bizservice)** — service + method registration face.
80
82
  - **List view (query)** — design-time list-view header lifecycle with
81
83
  forced LIST/showOnPc/unpublished defaults.
@@ -13,11 +13,13 @@ description: 一页纸业务场景步骤清单(采购申请 / 请假审批 /
13
13
 
14
14
  1. `omc next "从零做采购申请:主子表、合计、条件审批、列表、按钮"`
15
15
  2. `omc intent schema`(或直接按下方拼装器链)
16
- 3. `omc model-scaffold --app <app> --code <code> --name 采购申请 --fields '[{"code":"title","name":"标题","propertyType":"SHORT_TEXT"},{"code":"amount","name":"合计","propertyType":"NUMERICAL","options":{"scale":2}},{"code":"details","name":"明细","propertyType":"CHILD_TABLE","subFields":[{"code":"item","name":"品名","propertyType":"SHORT_TEXT"},{"code":"qty","name":"数量","propertyType":"NUMERICAL","options":{"scale":0}},{"code":"price","name":"单价","propertyType":"NUMERICAL","options":{"scale":2}},{"code":"lineAmount","name":"金额","propertyType":"NUMERICAL","options":{"scale":2}}]}]' --apply`
17
- 4. `omc compute --schema <code> --target amount --formula 'SUM({details.lineAmount})' --apply`(⚠️ 只在门户算,REST 不回填)
16
+ 3. `omc model-scaffold --app <app> --code <code> --name 采购申请 --fields '[{"code":"title","name":"标题","propertyType":"SHORT_TEXT"},{"code":"amount","name":"合计","propertyType":"NUMERICAL","options":{"scale":2}},{"code":"<主表code>_details","name":"明细","propertyType":"CHILD_TABLE","subFields":[{"code":"item","name":"品名","propertyType":"SHORT_TEXT"},{"code":"qty","name":"数量","propertyType":"NUMERICAL","options":{"scale":0}},{"code":"price","name":"单价","propertyType":"NUMERICAL","options":{"scale":2}},{"code":"lineAmount","name":"金额","propertyType":"NUMERICAL","options":{"scale":2}}]}]' --apply`
17
+ 子表 code 是全局子 schema code,必须全局唯一;不要使用 `details`、`items` 这类通用名,按 `<主表code>_details` 这类带主表前缀的形式命名,避免撞名 40000。
18
+ 4. `omc compute --schema <code> --target amount --formula 'SUM({<主表code>_details.lineAmount})' --apply`(⚠️ 只在门户算,REST 不回填)
18
19
  5. 条件审批(金额>50000 两级):**不要** `approval-flow --condition`(无此参数)。线性先:`omc approval-flow --schema <code> --steps '经理@role:<mgr>' --steps '总监@role:<dir>' --apply`;条件分流改 `omc describe workflow.update --example workflow-conditional-branch` 后 `workflow.update`。
19
20
  6. 发起权限:`omc call workflow.addPermission --args '{"workflowCode":"<code>_wf","unitId":"<deptOrRoleId>","unitType":1}'`(这是**谁能发起**,不是行级可见范围)
20
- 7. 列表:`omc call listview.configure --args '{"schemaCode":"<code>","fieldCodes":["title","amount"]}'`
21
+ 7. 列表:`omc call listview.configure --args '{"schemaCode":"<code>","fieldCodes":["title","amount"],"conditions":[{"propertyCode":"assetNo","filterType":"Eq","defaultValue":"<设备编号>"}]}'`
22
+ 按设备编号等条件查询时用 `conditions`;形状以 `omc describe listview.configure --param conditions` 为准(至少提供 `propertyCode`,再按字段补 `filterType`/`defaultValue`)。
21
23
  8. 表单按钮:`omc call listview.configure --args '{"schemaCode":"<code>","buttons":[{"name":"提交审批","useLocation":"FORM","bindAction":"FLOW"}]}'`(形状以 describe 为准)
22
24
  9. ⚠️ **行级「申请人只看自己」当前工具面不支持**(`workflow.permissions` / `org.role.setUserScope` 都不是行级数据权限)
23
25
 
@@ -11,6 +11,11 @@ Grouped by family. For a specific tool's exact wire and valid shapes, follow the
11
11
  Read `result.blocker` (refused) or `result.status`+`result.reason` (uncertain).
12
12
  `result.retryable` is advisory only — the family decides what to do.
13
13
 
14
+ `status:"refused"` means a local preflight or policy gate stopped the call:
15
+ it necessarily sent zero platform writes; there is no need to second-guess
16
+ whether anything was written. A platform/wire error (`wire-business` or an
17
+ `errcode`) is different: the outcome can be uncertain, so use readback.
18
+
14
19
  ## 1. Write permission declined
15
20
 
16
21
  - `environment-write-disabled` — the environment profile has `writable:false`.
@@ -12,9 +12,9 @@ be treated as a second source. Read this, then follow the topics it names.
12
12
 
13
13
  ## 最小路径(先读这段就能开始)
14
14
 
15
- 0. **调用与检索(跨平台)**:`omc call ... --args '<json>'` 的内联 JSON 在 Windows PowerShell 下因引号转义不可靠——**PS 环境优先 `--args-file <path>`**(把 JSON 写临时文件再引用;解析失败的报错里也有此提示)。读命令输出默认把中文转义为 `\uXXXX`(防乱码)——**这是有意为之**:stdout 恒为「恰好一个合法 JSON envelope」,在任何代码页下都能被 `JSON.parse`/`ConvertFrom-Json` 还原(GitLab #17 T2 实证:曾让 `--utf8` 关掉转义,GBK 管道把信封结构解码坏、`ConvertFrom-Json` 中途失败)。`--utf8` 只让**人类诊断文本**(stderr/散文)按 UTF-8 原文输出;要连 stdout 也是中文明文,必须先让管道按 UTF-8 解码(`chcp 65001`)——只设 `OMC_ASCII_STDOUT=0` 是关写出端转义、不改管道解码,在 cp936 管道下会把信封解码坏。对输出做内容检索时**先解 JSON 再检索**(或 `--jq` 取值),"grep 搜不到中文"≠"没有配置"(静默假阴性坑)。stdout 恒为纯 JSON、人读进度在 stderr——脚本解析勿 `2>&1` 合流。
15
+ 0. **调用与检索(跨平台)**:`omc call ... --args '<json>'` 的内联 JSON 在 Windows PowerShell 下因引号转义不可靠——**PS 环境优先 `--args-file <path>`**(把 JSON 写临时文件再引用;解析失败的报错里也有此提示)。长 JSON、含引号或中文换行的负载统一可走 stdin:`echo '<json>' | omc call <tool> --args -`;`--args-file <path>` 则必须位于项目配置根内。读命令输出默认把中文转义为 `\uXXXX`(防乱码)——**这是有意为之**:stdout 恒为「恰好一个合法 JSON envelope」,在任何代码页下都能被 `JSON.parse`/`ConvertFrom-Json` 还原(GitLab #17 T2 实证:曾让 `--utf8` 关掉转义,GBK 管道把信封结构解码坏、`ConvertFrom-Json` 中途失败)。`--utf8` 只让**人类诊断文本**(stderr/散文)按 UTF-8 原文输出;要连 stdout 也是中文明文,必须先让管道按 UTF-8 解码(`chcp 65001`)——只设 `OMC_ASCII_STDOUT=0` 是关写出端转义、不改管道解码,在 cp936 管道下会把信封解码坏。对输出做内容检索时**先解 JSON 再检索**(或 `--jq` 取值),"grep 搜不到中文"≠"没有配置"(静默假阴性坑)。stdout 恒为纯 JSON、人读进度在 stderr——脚本解析勿 `2>&1` 合流。
16
16
 
17
- 0. **拼装器优先(高频写面一条命令)**:整模型建模(模型+N 字段+子表+发布+表单)`omc model-scaffold`;**存量模型加字段(建→发布→重建表单→列表加列)`omc field-add`**;字段计算 `omc compute`;条件→动作 `omc rule-when`;线性审批流 `omc approval-flow`;表单默认值 `omc form-default`;存量盘点 `data.list all:true`——命中这些形态先看 `--help`,比 recipe 手拼省 10+ 轮。
17
+ 0. **拼装器优先(高频写面一条命令)**:整模型建模(模型+N 字段+子表+发布+表单)`omc model-scaffold`;**存量模型加字段(建→发布→重建表单→列表加列)`omc field-add`**;字段计算 `omc compute`;条件→动作 `omc rule-when`;线性审批流 `omc approval-flow`;表单默认值 `omc form-default`;存量盘点 `data.list all:true`;收尾清理 `omc model-purge --code <schema>`(依赖倒序,默认 dry-run)+ `omc residue <prefix>` 证删净——命中这些形态先看 `--help`,比 recipe 手拼省 10+ 轮。
18
18
 
19
19
  0d. **改线上已有配置:按范围选路**——单点(字段/视图/规则/布局)用 `omc call` 只传要改的参数;存量加字段用 `omc field-add`;整包收敛写**最小** `intent.yaml`(只声明要管的)再 `validate`/`plan`/`apply`;整面正文用 `form.get` + `artifact get --jq` 取回再写。`intent readback` 只做**等价校验**、不是导出可编辑草稿;结构化配置没有 pull/apply(仅规则源码走 `omc programming`)。
20
20
 
@@ -28,6 +28,11 @@ be treated as a second source. Read this, then follow the topics it names.
28
28
  阶梯步骤已内联工具必填参数(`route.ladder[].tools`/`readTools`)与命中 recipe 的 `describe <tool> --example <id>`;据此直接执行——**不要再逐能力 `commands --lane`**,也不要先扫 `commands --json`/全部 `contract`/全部 `describe --full`。
29
29
  方法论按 `route.readSections` 逐节读:对每条 `{topic, section}` 执行 `omc help <topic> --section "<section>"`(单节 0.4–3.4KB)。`omc help <topic>` 默认只回节索引(约 1KB,含每节字节数);**不要 `--all` 读整篇**(5–14KB,读一次就留在后续每一轮窗口)。**带着具体问题(错误码/跨能力语义/某个词)直接 `omc help --search "<词>"`**——跨全部 topic 全文检索,命中节的正文随结果内联返回,一次调用就是答案;**严禁自己写 `for t in …; do omc help $t --all | grep …` 循环**(r18 实测 12 调用/176KB,检索一次 8KB 搞定)。
30
30
  1b. **改/删/重命名既有对象前先看影响面(本地规则图索引)**:`omc graph pull --env <env>` 一次拉全平台建本地索引(增量、秒级、带锁与 gap 报告),之后 `omc graph references <service|model|field> <code>` 离线答「谁引用了它」——一调替代逐模型 `rule.get` 枚举(r30 实测影响评估手工枚举 8×rule.get,graph 一调可达);`impact <kind> <code>` 看爆炸半径、`search <词>` 找规则 code、`status` 看索引新鲜度。索引不含规则条件叶子与流程绑定,命中后仍以 `rule.get` 原文为准。无索引时会拒绝并指路 pull。宿主已接 MCP 时用 `omc mcp-graph --path <项目根>`(工具 `omc_graph_explore`/`omc_graph_status`)。
31
+
32
+ **图谱只覆盖单应用时怎么按应用盘点(只读)**:先用
33
+ `omc call app.list` 列出应用,再用
34
+ `omc call model.list --args '{"applicationCode":"<code>"}'` 按应用盘点模型。
35
+ graph 索引 coverage 默认只含配置应用;coverage 未命中不等于应用或模型不存在。
31
36
  1c. **写后端 Java 源码(在线开发)走工作区而不是一条条 call**:`omc programming pull --env <env> --app <appCode>` 把业务规则源码拉进 `.omc-programming/`(git 可管),改文件后 `omc programming status` → `omc programming apply [--publish]`(逐文件走编程面治理);图形化规则转编码用 `omc programming adopt --schema <s> --rule <r>`;验证用 `omc programming test --schema <s> --rule <r>`(fixture 驱动:compile→publish→触发→断言→清理)。**平台运行时只执行已发布版**:只 save 不 publish 等于没改;publish 内部会编译并把规则切 CODING+enable。包路径/类名不得改。
32
37
  2. 写前只需读 `omc-config-draft-review`(人审门)与 `omc-five-piece-flow`(发现→草稿→权限→执行→回读);`omc-business-configuration` 按命中的能力分节读(见 `next` 的 `readSections`),不要整篇预取。
33
38
  3. 环境名以 `omc doctor` 的「环境=」或 `.omc-env` 为准;工具不确定先用默认 `describe`/`--example`,只有单个工具缺形状才用 `--full`;`describe` 带 `exampleHint`/`recipeHint` 时优先 `--example`。
@@ -80,6 +80,10 @@ omc help omc-write-hazards
80
80
  - 新模型先配列表视图再依赖 `data.list`。部分 8.6.26 读面在没有 query/view 配置时会把业务列投影为 null;单行核对可用 `data.load`。1.1.25 复证(2026-10-31):脚手架默认视图只含 createdTime/creater/name/sequenceNo 四个系统列,`data.list` 对不在列面的业务字段一律回 null——**null ≠ 数据丢失**,先 `listview.get` 看列面;修复=`listview.configure {fieldCodes:[…业务列…], replaceColumns:true}`,配完立即回值。
81
81
  - **默认列表视图不能单独 `listview.delete`**(平台回 `303025 列表是默认列表`);要清掉它就删模型(`model.delete` 级联)。清理时别把它当失败。
82
82
 
83
+ ## 删除链
84
+
85
+ `model.delete` 带走:默认表单/在线 JS/流程/列表视图/平台预置规则与数据规则;**不带走**:自定义业务规则、业务数据(须先删)。顺序:`onlinejs` → `data.delete` 逐条清数据 → 删自定义规则 → `omc model-purge --code <schema>`(依赖倒序,默认 dry-run)。漏传 `bizObjectId(s)` 会被 `tool-input-invalid` 拒绝并指路 `data.list` 取 id(1.1.25 批量 `bizObjectIds` 被拒,须逐个单删)——无「漏传=全删」暗门(真机实测)。清完 `data.load` 复核。
86
+
83
87
  ## 按钮
84
88
 
85
89
  - **列表/行级按钮**用 `listview.configure` 的 `buttons`(`useLocation: LIST|ROW`)。
@@ -164,9 +168,13 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
164
168
 
165
169
  - **本 CLI 只负责发布,不负责写 JS**。脚本内容的创作与增量合并不在本 CLI:若当前项目自带在线 JS 编写规范(例如其 `.cpdocs`/技能或项目文档),遵循它;否则向用户/设计者索取已定稿 `source`。不要在 omc 技能里手写/臆造 JS 逻辑。
166
170
  - 经 omc 发布时用 `onlinejs.compose`:把已定稿的 `source` 组合进平台 skeleton 的 `customScript`,再 `publish`,并回读 `publishedHtmlJson` + SHA-256。不要用裸脚本覆盖平台 skeleton。
167
- - **`replace:true` 对裸代码被拒是设计**:`replace` 必须传完整的 `(function(form){…})`,否则会写坏脚本槽让表单空白;同样,`merge` 传完整带外层的脚本也会被拒(避免嵌套丢内容)。远端 `customScript` 为空时,定稿脚本带外层 + `replace:true` 即可,不要把它当成缺陷。
171
+ - **`replace`/`merge` 拒绝形状是设计**:`replace` 必须传完整 `(function(form){…})`(裸代码会写坏脚本槽);`merge` 传完整外层脚本也被拒(避免嵌套丢内容)。远端脚本为空时:完整脚本 + `replace:true`。
168
172
  - 源码不能有 import/export,事件名必须在支持集合内;compose 后读回 draft/published HTML,浏览器不是默认步骤。
169
173
 
174
+ ### 规范查找顺序
175
+
176
+ 项目规范:沙盒/项目 `.cpdocs/` → 项目根技能/文档 → 任务书指定路径。cpdocs 的 `--confirm-sha256` 流水线与 omc 互不替代:compose 已内置 dry-run 指纹,等价=replace 前用 `onlinejs.get` 记当前 SHA。
177
+
170
178
  ## 固定验收顺序
171
179
 
172
180
  执行阶段以 `omc-five-piece-flow` 为准;写面安全只额外强调两点:写前先 `commands/describe/contract` + dry-run 静态校验,写后权威 readback,必要时再用 CLI 触发运行验证。
@@ -237,4 +245,3 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
237
245
  2. **来源子表字段是集合,不是逐行扇出**:`currentSchemaDataItem:"items.asset"` 会被解析成该子表所有行的值集合,目标主表仍只查询一次。写这个形状能存能发布,但运行 0 行命中(r5-asset 真机)。OMC 0.4.64 起 `rule-graph-cross-model-child-source-fanout` **拒绝**。
238
246
  3. **一张单多行逐件回写**(每条明细各改一条另一模型的主表行)没有可用的纯配置形状——走 BIZ_ACTION 自调二开接口,一次处理多行;单行回写用主表字段做 DYNAMIC 来源。
239
247
  4. **BRANCH 后接两个跨表 UPDATE** 真机报 50000;改用单个 UPDATE + 条件动作,或二开。
240
-