@stalfh233/omc-cli 0.5.11 → 0.5.13

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.
@@ -256,7 +256,7 @@ export function classifyResult(result) {
256
256
  const suffix = candidates.length > 0 ? `;did you mean: ${candidates.join("、")}` : ";用 `omc commands --json --compact --lane <lane>` 或 `omc next` 定位工具";
257
257
  return { status: "error", error: withNext({ code: "invalid-input", message: `unknown tool ${String(result.tool ?? "")}${suffix}`, retryable: false }), exitCode: CLI_EXIT_CODES.invalidInput };
258
258
  }
259
- if (result.blocker === "tool-input-invalid")
259
+ if (result.blocker === "tool-input-invalid" || result.blocker === "tool-input-unknown-keys")
260
260
  return {
261
261
  status: "error",
262
262
  error: { ...withNext({ code: "invalid-input", message: String(result.message ?? "tool input is invalid"), retryable: false }), nextCommand: `omc describe ${String(result.tool ?? "<tool>")}` },
package/dist/cli.js CHANGED
@@ -33,6 +33,11 @@ import { cmdFormDefault } from "./form-default.js";
33
33
  // --utf8:单次调用明文输出(等效 OMC_ASCII_STDOUT=0)。在 asciiStdout 缓存读取前生效。
34
34
  if (process.argv.includes("--utf8"))
35
35
  process.env.OMC_ASCII_STDOUT = "0";
36
+ // issue#9 C5:--utf8 是一等 flag 但帮助面零提及,用户退而设环境变量/chcp
37
+ // (65/163 次 bash 白付前导)。默认 ASCII 转义时打一行自述提示。
38
+ else if (process.env.OMC_ASCII_STDOUT !== "0" && process.argv.some((arg) => arg === "--help" || arg === "help")) {
39
+ process.env.OMC_UTF8_HINT = "1";
40
+ }
36
41
  import { commandIndex, commandInventory, describeContract, describeContractFull, describeContractTool, describeExample, describeParam, describeSchemaOnly, describeTool, describeToolFull, filterTools } from "./discovery.js";
37
42
  import { closestMatches } from "./closest-match.js";
38
43
  import { repeatFullDescribe } from "./describe-cache.js";
@@ -42,7 +47,7 @@ import { absencePointer } from "./capability-absences.js";
42
47
  import { intentConfigRoot, readIntentFile, validateIntentText } from "./intent/cli.js";
43
48
  import { intentSchemaBlock, intentExample, intentExampleIds } from "./intent/schema.js";
44
49
  import { planIntent, readbackIntent, applyIntent, intentWarnings } from "./intent/plan-apply.js";
45
- import { listSkills, readSkill, readSkillOutline, readSkillSection, searchSkills } from "./skills.js";
50
+ import { listSkills, readSkill, readSkillOutline, readSkillSection, searchSkills, suggestHelpTopics } from "./skills.js";
46
51
  import { parseToolInput, ToolInputError } from "./tool-types.js";
47
52
  import { WireError, wireCall } from "./wire.js";
48
53
  import { DEFAULT_PULL_CONCURRENCY, GraphIndexAbsentError, GraphPullLockHeldError, MAX_PULL_CONCURRENCY, impactGraph, publishGraphPull, queryGraphReferences, readGraphStatus, searchGraphRules, } from "./graph.js";
@@ -137,6 +142,21 @@ async function cmdSetup(store, args) {
137
142
  }
138
143
  console.error(`环境 ${name} 已保存(幂等,可随时重跑 omc setup)。凭据存储模式:${store.credentialsMode()}`);
139
144
  console.error(`安全建议:优先使用专用集成账号(只读+目标应用写权限)而非系统管理员账号;密码仅存于本机 ~/.config/omc/credentials(0600),请勿在对话或文档中传递。`);
145
+ // 2026-11-02 用户实测反馈:冷启动扫描(角色/模型/流程盘点)在没有 graph 索引
146
+ // 时退化为全量 dump。对齐安装器对 codegraph 的首建行为——setup 成功后顺手
147
+ // 拉一次 graph 索引(失败静默跳过,不阻塞 setup;下次 graph 命令会兜底)。
148
+ try {
149
+ const { CloudPivotSession } = await import("./session.js");
150
+ const resolved = store.resolveEnvironment(name);
151
+ const session = new CloudPivotSession(resolved.name, resolved.profile, store.readCredential(resolved.name));
152
+ await session.login();
153
+ const { publishGraphPull } = await import("./graph.js");
154
+ const pulled = await publishGraphPull(session);
155
+ console.error(`graph 索引已预热:${pulled.rules.length} 规则 / ${pulled.edges.length} 边(omc graph search/references 可用;环境变更后 omc graph pull 刷新)`);
156
+ }
157
+ catch (error) {
158
+ console.error(`graph 预热跳过(不影响使用):${error instanceof Error ? error.message : String(error)}`);
159
+ }
140
160
  return 0;
141
161
  }
142
162
  async function cmdDoctor(store, environment, serviceCode) {
@@ -204,7 +224,7 @@ async function cmdDoctor(store, environment, serviceCode) {
204
224
  const errcode = error instanceof WireError ? error.errcode : undefined;
205
225
  if (errcode === 109001 || /许可证|license/i.test(error.message)) {
206
226
  ok = false;
207
- steps.push(`许可证 FAIL 已过期(平台拒绝 apppackage/list:${String(errcode ?? "")})——续期或更换环境;否则几乎全部读面与所有写面都会被 109001 拒绝`);
227
+ steps.push(`许可证 FAIL 已过期(平台拒绝 apppackage/list:${String(errcode ?? "")})——续期或更换环境。注意:109001 是**分面拦截**不是全环境不可用(r40 实证:报表/集成/部分读面被拒,模型/字段/数据面仍可交付)——不要因本项 FAIL 一刀切停步,按需逐面探测并用最小写验证`);
208
228
  }
209
229
  else {
210
230
  ok = false;
@@ -1064,6 +1084,21 @@ async function cmdGraphPull(environment, concurrency, transport) {
1064
1084
  try {
1065
1085
  const session = await context.sessions.session(environment);
1066
1086
  const data = await publishGraphPull(session, workers);
1087
+ // issue#9 C4:扫到 0 模型仍回 ok,让调用者误以为索引就绪(随后 search 报
1088
+ // graph-index-absent 才发现)。0 模型 fail-fast,点名 env/applicationCode
1089
+ // 与解析来源,把「索引没建成」和「建成了但为空」分开。
1090
+ if (data.scanned.models === 0) {
1091
+ const resolution = data.meta.applicationResolution;
1092
+ return emitResult({
1093
+ status: "refused",
1094
+ blocker: "graph-pull-empty-application",
1095
+ environment: data.meta.environmentName,
1096
+ applicationCode: data.meta.applicationCode,
1097
+ applicationResolution: resolution,
1098
+ message: `graph pull 解析到应用 ${data.meta.applicationCode ?? "?"}(来源:${resolution?.source ?? "unknown"}${resolution?.applicationCount !== undefined ? `,租户共 ${resolution.applicationCount} 个应用` : ""})后扫描到 0 个模型——索引未建立,graph search/references 会持续 graph-index-absent。检查:①应用里确实有模型?②applicationCode 配置是否指向了空应用(omc setup 里的 应用= 字段);③多应用租户须显式指定(graph pull --env <env> 的环境配置里写对 applicationCode)。`,
1099
+ nextCommand: `omc setup --list`,
1100
+ }, transport);
1101
+ }
1067
1102
  const edgesByKind = {};
1068
1103
  for (const edge of data.edges)
1069
1104
  edgesByKind[edge.kind] = (edgesByKind[edge.kind] ?? 0) + 1;
@@ -1361,7 +1396,8 @@ function cmdHelpSearch(topic, query, transport, searchMode, limit) {
1361
1396
  if (singleSection !== undefined) {
1362
1397
  const sliced = readSkillSection(topic, singleSection);
1363
1398
  if (sliced === undefined) {
1364
- const message = `未知 topic "${topic}";可用 topic:${topics.map((entry) => entry.name).join("、")}(命令清单见 omc help commands)${commandPointer}`;
1399
+ const suggestions = suggestHelpTopics(topic);
1400
+ const message = `未知 topic "${topic}";可用 topic:${topics.map((entry) => entry.name).join("、")}(命令清单见 omc help commands)${commandPointer}${suggestions.length > 0 ? `;你可能要找:${suggestions.join("、")}` : ""}`;
1365
1401
  console.error(`help: ${message}`);
1366
1402
  return emitInvalid(transport, { message });
1367
1403
  }
@@ -1382,7 +1418,8 @@ function cmdHelpSearch(topic, query, transport, searchMode, limit) {
1382
1418
  }
1383
1419
  const document = readSkill(topic);
1384
1420
  if (document === undefined) {
1385
- const message = `未知 topic "${topic}";可用 topic:${topics.map((entry) => entry.name).join("、")}(命令清单见 omc help commands)${commandPointer}`;
1421
+ const suggestions = suggestHelpTopics(topic);
1422
+ const message = `未知 topic "${topic}";可用 topic:${topics.map((entry) => entry.name).join("、")}(命令清单见 omc help commands)${commandPointer}${suggestions.length > 0 ? `;你可能要找:${suggestions.join("、")}` : ""}`;
1386
1423
  console.error(`help: ${message}`);
1387
1424
  return emitInvalid(transport, { message });
1388
1425
  }
@@ -1423,6 +1460,7 @@ function cmdHelpSearch(topic, query, transport, searchMode, limit) {
1423
1460
  function onboardingHelp() {
1424
1461
  return [
1425
1462
  "首次使用、或不确定从哪开始:omc help omc-start-here(随包权威 topic 路线);被拒或状态异常:omc help omc-refusals。",
1463
+ "中文可读输出:任何命令加 --utf8(如 omc --version --utf8)——默认终端输出会把非 ASCII 字符转义,中文场景先加它,不必设环境变量或 chcp。",
1426
1464
  "方法论按需读:omc help <topic> 默认只回节索引(每节字节数);用 --section \"<标题>\" 取正文(可重复),确需整篇才 --all。找具体事实(错误码/字段名/命令名)不要整篇 dump:omc help --search \"<词>\"(跨 topic,命中节正文随结果返回)。",
1427
1465
  "目标→工具一步定位:omc next \"<你要做什么>\"(离线、只读,给能力/说明/首批命令;不确定会明说并回退到 omc-requirement-analysis)。",
1428
1466
  "大结果取值一步到位:读面回 detailArtifactId 时,用 omc call <tool> … --jq <path>(可重复;`[]` 映射数组,如 items[].fields.planNo)或 --extract/--extract-keys,省掉再跑一次 omc artifact get;artifact get 正文超 8KB 默认也只回形状(--jq/--keys 定向取,--full 才整包)。批量形状预检用 --args-list … --preflight(零 wire、零写入,一次报全)。",
@@ -326,8 +326,14 @@ export function registerBusinessRuleLane(tools) {
326
326
  let semanticFindings;
327
327
  try {
328
328
  const { validateRuleNodeGraph } = await import("./rule-graph.js");
329
- const graphForLint = { nodes: projected.nodes, routes: projected.routes };
330
- const findings = validateRuleNodeGraph(graphForLint, record.code === undefined ? undefined : String(record.code), record.schemaCode === undefined ? undefined : String(record.schemaCode));
329
+ // r45 I5 误报根因:体检重建图只带 nodes/routes,把行级 schedulerSetting
330
+ // 丢了——定时规则读回恒报 scheduler-setting-missing(实际已持久化)。
331
+ const recordSc = record.schedulerSetting;
332
+ const graphForLint = { nodes: projected.nodes, routes: projected.routes, ...(recordSc !== undefined && recordSc !== null ? { schedulerSetting: recordSc } : {}) };
333
+ const findings = validateRuleNodeGraph(graphForLint, record.code === undefined ? undefined : String(record.code), record.schemaCode === undefined ? undefined : String(record.schemaCode)).filter(
334
+ // r45 I5:读回体检只看「平台持久化后仍有害」的形状——UI 坐标类
335
+ // (route points)平台自己会归一化/补全,读回图缺失属正常,不是隐患。
336
+ (finding) => finding.code !== "rule-graph-route-points");
331
337
  if (findings.length > 0) {
332
338
  semanticFindings = findings.map((finding) => ({
333
339
  code: finding.code,
@@ -193,6 +193,7 @@ export function registerModelLane(tools) {
193
193
  environment: environmentArg,
194
194
  applicationCode: z.string().optional().describe("应用 code;省略用配置/默认应用"),
195
195
  filter: z.string().optional().describe("子串过滤:code 或 name 含该词才保留"),
196
+ search: z.string().optional().describe("filter 的别名(集合读统一关键字手感;org.role.list/service.list 也用 search)"),
196
197
  capability: capabilityArg,
197
198
  },
198
199
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
@@ -205,7 +206,7 @@ export function registerModelLane(tools) {
205
206
  // the tree row's key. Echo both so `--jq 'models[].schemaCode'` works
206
207
  // (r15 P6: the alias was missing and the path missed).
207
208
  .map((row) => ({ code: row.code, schemaCode: row.code, name: row.name ?? row.text ?? null }));
208
- const needle = args.filter?.trim();
209
+ const needle = (args.filter ?? args.search)?.trim();
209
210
  const models = needle ? all.filter((model) => String(model.code).includes(needle) || String(model.name ?? "").includes(needle)) : all;
210
211
  // `filterApplied` disambiguates an empty result: it distinguishes "the
211
212
  // filter ran and matched nothing" from "the filter was not applied"
@@ -305,7 +306,23 @@ export function registerModelLane(tools) {
305
306
  // to hide "system" rules without dropping authored ones. `defaultRule` is
306
307
  // reported per rule so a caller can decide.
307
308
  const rules = ruleRows.map((rule) => ({ code: rule.code, name: rule.name, enabled: rule.enabled, status: rule.status, ruleVersion: rule.ruleVersion, defaultRule: rule.defaultRule }));
308
- const dataruleRows = Array.isArray(dataruleFace?.rules) ? dataruleFace.rules : [];
309
+ // r46 实证:datarule.list 超阈值走 artifact(rulesCount>0 但无内联 rules),
310
+ // 聚合面读 .rules 恒空——摘要答 0 条、实际 17+。内联缺行时解引用 artifact。
311
+ let dataruleRows = Array.isArray(dataruleFace?.rules) ? dataruleFace.rules : [];
312
+ if (dataruleRows.length === 0 && Number(dataruleFace?.rulesCount ?? 0) > 0 && typeof dataruleFace?.detailFile === "string") {
313
+ // r46:handler 层大结果给的是 detailFile(ctx.artifactsDir 下的文件),
314
+ // 无内联 rules——聚合面须读回产物文件,否则摘要恒 0。
315
+ try {
316
+ const { readFileSync } = await import("node:fs");
317
+ const content = JSON.parse(readFileSync(String(dataruleFace.detailFile), "utf8"));
318
+ const fromArtifact = Array.isArray(content?.rules) ? content.rules : [];
319
+ if (fromArtifact.length > 0)
320
+ dataruleRows = fromArtifact;
321
+ }
322
+ catch {
323
+ // 产物缺失/损坏时保持空摘要——不因解引用失败拖垮整个 inspect
324
+ }
325
+ }
309
326
  const datarules = dataruleRows.map((rule) => ({ propertyCode: rule.propertyCode, propertyCodes: rule.propertyCodes, dataRuleType: rule.dataRuleType, name: rule.name, checkType: rule.checkType }));
310
327
  // Workflow summary: use the PUBLISHED face for the `published` flag. The
311
328
  // draft face's templateType is ALWAYS "DRAFT" (even for a published
@@ -995,6 +995,24 @@ export function registerRuntimeDataLane(tools) {
995
995
  const rawSaveData = args.formData;
996
996
  const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
997
997
  const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
998
+ // issue#9 C1(用户实测最高优先):data.submit 有 submit-person-field-shape 等
999
+ // 写前 gate,data.save 同类形状错误裸奔——对象数组人员字段在 save 面直落
1000
+ // 平台 50000(UserBizField.fillClientContent),同类失败重复 3 次还引发
1001
+ // 「环境不可写」误判。save 面共用同一 gate(注意:save 草稿面接受对象数组
1002
+ // ——只有 submit:true 直提路径才必须字符串 id,见 hazards 通道分叉条)。
1003
+ if (args.submit === true) {
1004
+ const staffShapedSave = await personFieldShapeViolations(session, args.schemaCode, formData).catch(() => []);
1005
+ if (staffShapedSave.length > 0)
1006
+ throw new ToolRefusalError({
1007
+ status: "refused",
1008
+ blocker: "submit-person-field-shape",
1009
+ schemaCode: args.schemaCode,
1010
+ fields: staffShapedSave,
1011
+ message: `data.save submit:true 的人员字段形状错误:${staffShapedSave.map((entry) => `${entry.code}(${entry.got})`).join("、")}。` +
1012
+ `提交面只接受字符串 id("<用户id>" 或 ["<id1>","<id2>"]);对象数组 [{id,type,name}] 会触发平台 UserBizField.fillClientContent 断言 50000。` +
1013
+ `两条出路:①改成字符串 id 数组重提;②本字段留对象数组、去掉 submit:true 先存草稿,再 data.submit(两步法,草稿面接受对象数组)。`,
1014
+ });
1015
+ }
998
1016
  if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
999
1017
  // Portal draft-save shape. The row id travels in data.id (the portal
1000
1018
  // form SDK receives a pre-generated id from form/load and echoes it);
@@ -180,7 +180,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
180
180
  }),
181
181
  "workflow.addPermission": Object.freeze({
182
182
  scope: "workflow start permission (who may originate)",
183
- description: "授予发起权限(`POST /api/api/workflow/create_permission`,body `{workflowCode,unitId,unitType,corpId}`;unitType 取枚举序号 1=部门 2=角色 3=用户)。unitId 用 org.department.list / org.role.list / org.user.search 取。重复授予按 402003 幂等跳过,写后回读确认。",
183
+ description: "授予**流程发起权限**——谁能发起这条流程(`POST /api/api/workflow/create_permission`,body `{workflowCode,unitId,unitType,corpId}`;unitType 枚举序号 1=部门 2=角色 3=用户)。⚠️ 命名易混(r46 实测绕路):这里**不是**流程节点的字段权限——节点字段可见/可编辑(propertyPermissions)走 `workflow.update --merge`;本工具只管发起人白名单。unitId 用 org.department.list / org.role.list / org.user.search 取。重复授予按 402003 幂等跳过,写后回读确认。",
184
184
  }),
185
185
  "workflow.removePermission": Object.freeze({
186
186
  scope: "workflow start permission (who may originate)",
@@ -1423,6 +1423,23 @@ async function refuseRequiredOnChildTableFields(session, schemaCode, template, t
1423
1423
  * same edge). This is the "read back, then send the delta" path — without it a
1424
1424
  * subset silently REPLACED the whole activity list and dropped nodes.
1425
1425
  */
1426
+ function refusePartialBtnConfigList(activities) {
1427
+ // r32 实证(backlog 清算批):btnConfigList 局部行=整行替换——只传 {code, remark}
1428
+ // 会把 enabled 静默置 false、commentPermission 置 null(驳回意见必填丢失)。
1429
+ // fail-closed:按钮行不带显式 enabled 一律拒绝,指路 hazards 的整行回写法。
1430
+ for (const activity of activities) {
1431
+ const rows = activity.btnConfigList;
1432
+ if (!Array.isArray(rows))
1433
+ continue;
1434
+ const partial = rows.filter((row) => row !== null && typeof row === "object" && !("enabled" in row));
1435
+ if (partial.length > 0)
1436
+ throw new ToolRefusalError({
1437
+ status: "refused",
1438
+ blocker: "workflow-btnconfig-partial-row",
1439
+ message: `workflow.update: 节点 ${String(activity.activityCode ?? activity.id ?? "")} 的 btnConfigList 存在不带 enabled 的按钮行(${partial.map((row) => String(row.code ?? "?")).join("、")})——该平台按**整行替换**处理,缺省键会被静默重置(enabled→false、commentPermission→null)。先 workflow.get_published/get_draft 取该按钮整行,改目标属性后整条回写(见 omc help omc-write-hazards --section 按钮)。`,
1440
+ });
1441
+ }
1442
+ }
1426
1443
  function mergeCallerDelta(template, draft, options = {}) {
1427
1444
  const { routes: routesRaw, rules: rulesRaw, activities: _raw, edges: edgesRaw, ...rest } = template;
1428
1445
  // `routes` and `edges` are the recipe spellings; `rules` is the platform DTO
@@ -1486,6 +1503,7 @@ function mergeCallerDelta(template, draft, options = {}) {
1486
1503
  return { ...activity, ...override };
1487
1504
  });
1488
1505
  const newActivities = [...callerByOriginal.values()];
1506
+ refusePartialBtnConfigList([...mergedActivities, ...newActivities]);
1489
1507
  // A caller-only code that arrived WITHOUT a type has no draft row to inherit
1490
1508
  // from — refuse it here (the lint allows type-less permission-shape rows
1491
1509
  // precisely because they are expected to match an existing node).
@@ -1595,6 +1613,8 @@ function normalizeWorkflowTemplate(template, draft, options = {}) {
1595
1613
  // comment); give every changed/new node a fresh code, and remap caller routes
1596
1614
  // so they follow.
1597
1615
  const reconciled = rawCaller ? reconcileCallerActivityCodes(rawCaller, draftActivities, options) : undefined;
1616
+ if (rawCaller !== undefined)
1617
+ refusePartialBtnConfigList(rawCaller);
1598
1618
  const activities = reconciled?.activities;
1599
1619
  const codeMap = reconciled?.codeMap ?? new Map();
1600
1620
  const remapCode = (value) => codeMap.get(String(value ?? "")) ?? value;
@@ -2088,10 +2108,49 @@ export function registerWorkflowLane(tools) {
2088
2108
  environment: environmentArg,
2089
2109
  workflowCode: z.string().optional().describe("流程 code;也可改用 schemaCode(工具内部按 workflow/isworkflow + workflow/getWorkflow 解析)"),
2090
2110
  schemaCode: z.string().optional().describe("模型 code:省略 workflowCode 时用它解析该模型已发布的流程;模型没有流程则 exists:false"),
2111
+ participantSearch: z.string().min(1).optional().describe("反向检索模式(issue#9 C3):给角色/部门 code 或 name 子串,内部遍历全部模型的已发布流程,只回命中 participant 的节点——替代逐模型 get_published 串行扫(15 模型×14KB 只为 1 行答案)"),
2091
2112
  full: z.boolean().optional().describe("内联完整工作流模板;默认返回摘要与 detailFile"),
2092
2113
  capability: capabilityArg,
2093
2114
  },
2094
2115
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
2116
+ // issue#9 C3 反向检索:一次调用内遍历模型 → 已发布流程摘要 → participant
2117
+ // 子串命中,输出只含命中行(模型数 N 次 wire 在内部完成,上下文只付命中行)。
2118
+ const participantSearch = args.participantSearch;
2119
+ if (participantSearch !== undefined) {
2120
+ const needle = participantSearch.trim().toLowerCase();
2121
+ const hits = [];
2122
+ let scannedModels = 0;
2123
+ let withWorkflow = 0;
2124
+ const modelRows = wireRows((await wireCall(session, { path: ENDPOINTS.modelList, query: {} })).data).filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code.length > 0);
2125
+ for (const row of modelRows) {
2126
+ scannedModels += 1;
2127
+ const schemaCode = String(row.code);
2128
+ let code;
2129
+ try {
2130
+ code = (await resolveWorkflowArg(session, { schemaCode })) ?? undefined;
2131
+ }
2132
+ catch {
2133
+ code = undefined;
2134
+ }
2135
+ if (!code)
2136
+ continue;
2137
+ withWorkflow += 1;
2138
+ try {
2139
+ const pub = await wireCall(session, { path: ENDPOINTS.workflowGetPublished, query: { workflowCode: code } });
2140
+ const summary = workflowCompactProjection({ data: pub.data });
2141
+ for (const activity of summary.workflowSummary?.activities ?? []) {
2142
+ const participant = activity.participant === undefined ? "" : String(activity.participant);
2143
+ if (participant.toLowerCase().includes(needle)) {
2144
+ hits.push({ schemaCode, workflowCode: code, activityCode: activity.code, activityName: activity.name, participant: activity.participant });
2145
+ }
2146
+ }
2147
+ }
2148
+ catch {
2149
+ // 单模型读失败不中断整轮检索
2150
+ }
2151
+ }
2152
+ return { keyword: participantSearch, scannedModels, withWorkflow, hits, hitsCount: hits.length };
2153
+ }
2095
2154
  const workflowCode = await resolveWorkflowArg(session, args);
2096
2155
  if (!workflowCode)
2097
2156
  return { schemaCode: args.schemaCode, exists: false, published: false, note: "该模型没有已发布流程(workflow/isworkflow=false);先 workflow.create 再 publish" };
@@ -3129,7 +3188,7 @@ export function registerWorkflowLane(tools) {
3129
3188
  lane: WORKFLOW_LANE,
3130
3189
  scope: WORKFLOW_TOOLS["org.role.list"].scope,
3131
3190
  description: WORKFLOW_TOOLS["org.role.list"].description,
3132
- inputSchema: { environment: environmentArg, expandAll: z.boolean().optional().describe("默认 true:展开所有角色组并带回角色。平台的 /role/list 只有 expandAll=true 才填 children(RoleController.list,1.1.25);传 false 只回角色组、roles 恒为空,别据此判断「角色未创建」"), capability: capabilityArg },
3191
+ inputSchema: { environment: environmentArg, search: z.string().min(1).optional().describe("关键字过滤(CLI 侧,按 code/name 不区分大小写子串)——issue#9 C2:113 角色全量 dump 34KB 只为找一个角色 code 的实测痛点;服务端无关键字面,过滤在本地做"), expandAll: z.boolean().optional().describe("默认 true:展开所有角色组并带回角色。平台的 /role/list 只有 expandAll=true 才填 children(RoleController.list,1.1.25);传 false 只回角色组、roles 恒为空,别据此判断「角色未创建」"), capability: capabilityArg },
3133
3192
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
3134
3193
  // `/role/list` without `expandAll=true` only expands the FIRST group's
3135
3194
  // children (RoleController.list, 1.1.25), so a role created under any
@@ -3141,7 +3200,23 @@ export function registerWorkflowLane(tools) {
3141
3200
  path: ENDPOINTS.organizationRoleList,
3142
3201
  query: expandAll ? { expandAll: "true" } : {},
3143
3202
  })).data;
3144
- return flattenRoleTree(wireRows(data));
3203
+ const flat = flattenRoleTree(wireRows(data));
3204
+ // issue#9 C2:冷启动按关键字取角色,避免全量进上下文
3205
+ const search = args.search;
3206
+ if (typeof search === "string" && search.trim().length > 0) {
3207
+ const needle = search.trim().toLowerCase();
3208
+ const roles = flat.roles ?? [];
3209
+ const groups = flat.groups ?? [];
3210
+ const hit = (row) => String(row.code ?? "").toLowerCase().includes(needle) || String(row.name ?? "").toLowerCase().includes(needle);
3211
+ return {
3212
+ ...flat,
3213
+ roles: roles.filter(hit),
3214
+ groups: groups.filter(hit),
3215
+ filteredBy: search,
3216
+ totalBeforeFilter: roles.length,
3217
+ };
3218
+ }
3219
+ return flat;
3145
3220
  }),
3146
3221
  }));
3147
3222
  tools.push(defineTool({
package/dist/read.js CHANGED
@@ -103,6 +103,20 @@ function compactReadback(result) {
103
103
  if (BULKY_READ_KEYS.has(key)) {
104
104
  compact[`${key}Count`] = Array.isArray(value) ? value.length : value && typeof value === "object" ? Object.keys(value).length : 0;
105
105
  }
106
+ else if (ACTIVITY_LIST_KEYS.has(key) && Array.isArray(value)) {
107
+ compact[key] = value.map(activityOfEntry).filter((entry) => entry !== undefined);
108
+ compact[`${key}Count`] = value.length;
109
+ }
110
+ else if (CODE_NAME_LIST_KEYS.has(key) && Array.isArray(value)) {
111
+ const pairs = value.map(codeNameOfEntry).filter((entry) => entry !== undefined);
112
+ // 40 对上限(~1-2KB):更大的清单回退 count+artifact,保冷启动可扫又不失控
113
+ if (pairs.length <= 40)
114
+ compact[key] = pairs;
115
+ else
116
+ compact[`${key}Count`] = value.length;
117
+ if (!compact[`${key}Count`])
118
+ compact[`${key}Count`] = value.length;
119
+ }
106
120
  else if (CODE_LIST_KEYS.has(key) && Array.isArray(value)) {
107
121
  // 2026-11-01 预算批 v2:读门 4096→2048 后,中结果(r38 实测 model.inspect
108
122
  // 17 次 51KB 全部卡旧门下全内联)走 compact——配置清单类数组保形为码表
@@ -117,7 +131,32 @@ function compactReadback(result) {
117
131
  return compact;
118
132
  }
119
133
  /** Config-inventory keys that compact to a code list (not a bare count). */
120
- const CODE_LIST_KEYS = new Set(["fields", "columns", "conditions", "sorts", "datarules", "buttons", "views"]);
134
+ const CODE_LIST_KEYS = new Set(["fields", "columns", "conditions", "sorts", "datarules", "buttons", "views", "sheets"]);
135
+ /**
136
+ * 码不自解释的清单(2026-11-02 用户实测反馈):角色/模型/部门冷启动扫描
137
+ * 是刚需,但裸 code(SYS_1000000)不可读、全量 dump 又是 113×全字段的
138
+ * 上下文灾难——内联为 `code|name` 对,超大时仍回退 count+artifact。
139
+ */
140
+ const CODE_NAME_LIST_KEYS = new Set(["roles", "groups", "models", "departments", "workflows", "services", "methods"]);
141
+ /** 节点类清单:参与者是扫描目标(r46 用户实测:逐模型拉全权限数组找审批人)——
142
+ * 内联 `code:name@participant`,权限明细仍在 artifact。 */
143
+ const ACTIVITY_LIST_KEYS = new Set(["activities"]);
144
+ function codeNameOfEntry(entry) {
145
+ const code = codeOfEntry(entry);
146
+ if (code === undefined)
147
+ return undefined;
148
+ const name = typeof entry.name === "string" && entry.name.length > 0 ? entry.name : undefined;
149
+ return name === undefined ? code : `${code}|${name}`;
150
+ }
151
+ function activityOfEntry(entry) {
152
+ const code = typeof entry.code === "string" && entry.code.length > 0 ? entry.code : typeof entry.activityCode === "string" ? entry.activityCode : undefined;
153
+ if (code === undefined)
154
+ return undefined;
155
+ const name = typeof entry.name === "string" && entry.name.length > 0 ? entry.name : "";
156
+ const participantRaw = entry.participant ?? entry.participants;
157
+ const participant = typeof participantRaw === "string" ? participantRaw : participantRaw && typeof participantRaw === "object" ? JSON.stringify(participantRaw) : "";
158
+ return `${code}${name ? `:${name}` : ""}${participant ? `@${participant.slice(0, 48)}` : ""}`;
159
+ }
121
160
  function codeOfEntry(entry) {
122
161
  for (const key of ["propertyCode", "code", "schemaCode", "ruleCode", "fieldCode"]) {
123
162
  const value = entry[key];
package/dist/skills.js CHANGED
@@ -48,6 +48,57 @@ export function listSkills() {
48
48
  return { name, description };
49
49
  });
50
50
  }
51
+ /**
52
+ * r43 后 backlog:`omc help 编号` 曾零命中(高频中文业务词不是 topic 名)。
53
+ * 双路建议:①精选同义词表(高频业务词→topic,r24–r43 探针实测检索失败样本)
54
+ * ②全 topic 节标题子串扫描(自维护——节名改了建议跟着变)。
55
+ */
56
+ const HELP_TOPIC_SYNONYMS = Object.freeze({
57
+ 编号: ["omc-capabilities"],
58
+ 序列号: ["omc-capabilities"],
59
+ 单号: ["omc-capabilities"],
60
+ 流水号: ["omc-capabilities"],
61
+ 自动编号: ["omc-capabilities"],
62
+ 审批: ["omc-five-piece-flow"],
63
+ 流程: ["omc-five-piece-flow"],
64
+ 回写: ["omc-business-configuration"],
65
+ 跨模型: ["omc-business-configuration"],
66
+ 规则: ["omc-business-configuration"],
67
+ 字典: ["omc-business-configuration"],
68
+ 权限: ["omc-business-configuration"],
69
+ 视图: ["omc-write-hazards"],
70
+ 过滤: ["omc-write-hazards"],
71
+ 条件: ["omc-write-hazards"],
72
+ 删除: ["omc-write-hazards"],
73
+ 清理: ["omc-write-hazards"],
74
+ 子表: ["omc-write-hazards"],
75
+ 表单: ["omc-write-hazards"],
76
+ 验收: ["omc-acceptance-criteria"],
77
+ 入门: ["omc-start-here"],
78
+ 拒绝: ["omc-refusals"],
79
+ });
80
+ export function suggestHelpTopics(query) {
81
+ if (query === undefined || query.length === 0)
82
+ return [];
83
+ const hits = new Set(HELP_TOPIC_SYNONYMS[query] ?? []);
84
+ for (const skill of listSkills()) {
85
+ if (skill.name.includes(query) || (skill.description ?? "").includes(query))
86
+ hits.add(skill.name);
87
+ }
88
+ // 节标题扫描:`omc help <topic> --section "<heading>"` 直达
89
+ for (const name of listSkills().map((skill) => skill.name)) {
90
+ const outline = readSkillOutline(name);
91
+ if (outline === undefined)
92
+ continue;
93
+ for (const section of outline.sections) {
94
+ if (section.heading.includes(query)) {
95
+ hits.add(`omc help ${name} --section "${section.heading}"`);
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ return [...hits].slice(0, 5);
101
+ }
51
102
  /** Read one methodology topic for `omc help <topic>`. */
52
103
  export function readSkill(name) {
53
104
  if (!/^[a-z0-9-]+$/.test(name))
@@ -91,6 +91,9 @@ function applyInputAliases(tool, input) {
91
91
  continue;
92
92
  normalized ??= { ...input };
93
93
  normalized[canonical] = input[hit];
94
+ // 同义词键就地删除:归一后的输入只留规范键——写面未知键 fail-closed
95
+ // (r43 后)不把已消费的同义词误报成未知键。
96
+ delete normalized[hit];
94
97
  }
95
98
  return normalized ?? input;
96
99
  }
@@ -111,8 +114,53 @@ function keyRenameHint(issuePath, input, tool) {
111
114
  // "你传的是 schemaCode", which pointed at the wrong key.
112
115
  return `(该对象在本工具里的键是 ${key},你传的是 ${synonyms.join("/")};键名对照见 omc-tool-selection 的 Key-name table)`;
113
116
  }
117
+ /** Cheap closest-key suggestion: exact→prefix→shared-start bigrams, threshold keeps it honest. */
118
+ function closestSchemaKey(key, schemaKeys) {
119
+ if (schemaKeys.has(key))
120
+ return key;
121
+ let best;
122
+ let bestScore = 0;
123
+ for (const candidate of schemaKeys) {
124
+ const lower = key.toLowerCase();
125
+ const candLower = candidate.toLowerCase();
126
+ let score = 0;
127
+ if (candLower.startsWith(lower) || lower.startsWith(candLower))
128
+ score = 0.9;
129
+ else if (candLower.includes(lower) || lower.includes(candLower))
130
+ score = 0.7;
131
+ else {
132
+ let shared = 0;
133
+ for (let i = 0; i + 2 <= lower.length; i++)
134
+ if (candLower.includes(lower.slice(i, i + 2)))
135
+ shared++;
136
+ score = lower.length > 2 ? shared / (lower.length - 1) : 0;
137
+ }
138
+ if (score > bestScore) {
139
+ bestScore = score;
140
+ best = candidate;
141
+ }
142
+ }
143
+ return bestScore >= 0.4 ? best : undefined;
144
+ }
114
145
  export function parseToolInput(tool, input) {
115
146
  const normalized = applyInputAliases(tool, input);
147
+ // r30 引出、r42/r43 再证:静默丢弃错键是头号返工源(dictionary.save items
148
+ // 未知键、跨模型 filterCondition 错键同族)。写面 fail-closed——未知顶层键
149
+ // 当场拒绝并给最接近的合法键提示;读面保持宽松(探查性调用常见无害附加)。
150
+ if (tool.kind === "write") {
151
+ const schemaKeys = new Set(Object.keys(tool.inputSchema));
152
+ const unknown = Object.keys(normalized).filter((key) => !schemaKeys.has(key));
153
+ if (unknown.length > 0) {
154
+ const suggestions = unknown.map((key) => `${key}→${closestSchemaKey(key, schemaKeys) ?? "(无相近键,omc describe <tool> 看参数表)"}`);
155
+ throw new ToolInputError({
156
+ status: "error",
157
+ blocker: "tool-input-unknown-keys",
158
+ tool: tool.name,
159
+ message: `写面工具不接受未知键(会被静默丢弃):${unknown.join("、")}。最接近的合法键:${suggestions.join(";")}。参数表:omc describe ${tool.name}`,
160
+ issues: unknown.map((key) => ({ path: [key], code: "unrecognized_key", message: `unknown key; closest: ${closestSchemaKey(key, schemaKeys) ?? "none"}` })),
161
+ });
162
+ }
163
+ }
116
164
  const result = z.object(tool.inputSchema).safeParse(normalized);
117
165
  if (result.success)
118
166
  return result.data;
package/dist/write.js CHANGED
@@ -82,6 +82,9 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload) {
82
82
  reason: `wire-transport:${wireErrorKind(error)}`,
83
83
  detail: error.message,
84
84
  message: "write outcome cannot be established; frozen — re-discover before retrying; never assume success",
85
+ // r46 实证:data.submit 传输中断但平台已落行,按回执盲重试产生重复数据
86
+ // 再连锁清理。uncertain ≠ 失败——先按业务键查重再决定。
87
+ hint: "uncertain 不是失败:写可能已生效。data.submit/data.save 先 data.list 按业务键(单号/编码)查重,命中即成功勿重发;未命中再重试。",
85
88
  };
86
89
  if (error instanceof ToolRefusalError)
87
90
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@stalfh233/omc-fake-platform": "0.5.11",
55
+ "@stalfh233/omc-fake-platform": "0.5.13",
56
56
  "commander": "14.0.3",
57
- "omc-core": "0.5.11",
57
+ "omc-core": "0.5.13",
58
58
  "proper-lockfile": "4.1.2",
59
59
  "write-file-atomic": "6.0.0",
60
60
  "zod": "^3.23.8",
@@ -39,26 +39,26 @@ omc help omc-write-hazards
39
39
  - `propertyEmpty` 默认 `false`。模型层保持可空;必填、只读、可见性在表单和流程节点配置。
40
40
  - 无映射关联单选 `WORK_SHEET` 使用 `mappings: ""`,不要用 `"[]"`。`"[]"` 是非空字符串,设计器会显示“已设置”,但弹窗没有映射行。
41
41
  - 有映射时传非空 JSON 数组字符串,并在 draft/published 两面读回。
42
- - **运行时写关联值是另一种形状**(`data.save`/`data.update` 的 formData):单选关联(`propertyType 9`)传 **id 字符串或 `{ id }`**,传**数组会稳定 50000**;**多选关联(11)传分号分隔的 id 字符串 `"id1;id2"`(或 `{id:"id1;id2"}`),传数组稳定 50000**;**人员/组织(50/51/60/61)传 `[{"id","type","name"}]`,`type` 必填**(缺它稳定 50000)——`type` 是 UnitType 索引:**用户=3、部门=1**。用错形状只会拿到一个不透明的 50000。
42
+ - **人员字段形状随通道分叉(2026-11-02 用户实测)**:save/update 面=[{id,type,name}](type:用户 3/部门 1);**submit 面**(data.submit 或 save+submit:true)=**字符串 id 数组** ["<id>"]——对象数组+submit 触发平台 UserBizField.fillClientContent 断言 50000(OMC 预检会拦);一次提交人员字段被拒就改「先存草稿再 submit」两步。其余:单选(9)=id 串;多选(11)=分号串。
43
43
  - **`mappings` 由表单运行时在用户选择时套用**,服务端 `data.save` **不会**自动回填映射目标字段。API 侧要自己写被映射字段,否则读回为空——别把它误判成“映射配置失败”。
44
44
  - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 cascadeRepublish 刷带布局/在线JS 的表单**:rebuild 会清空设计器公式(已做写前拒绝)。
45
45
  - **运行时写值的最小形状**(组合出错只会得到一个不透明的 `50000`/NPE,逐一最小化再合):单选关联(9) → id 字符串 `"<id>"`;**多选关联(11) → 分号串 `"<id1>;<id2>"`**;**人员/组织(50/51/60/61) → `[{"id":"<id>","type":3,"name":"<名>"}]`,`type` 必填且为整数**(缺/非整数 → `BizField.parseFromClient` 拆箱 NPE→50000;用户=3、部门=1);日期(3) → 字符串 `"2026-09-30 10:00:00"`;子表(8) → 行对象数组 `[{"<子列code>":<值>, ...}]`。先各写一条最小行通过,再组合。
46
46
  其中三类已有**写前拒绝**(r17b 2026-10-26 起):日期收到毫秒时间戳(平台接受但落库 NULL)、日期字符串不匹配字段声明 format(50000)、单值关联收到数组(50000)——blocker `submit-date-shape` / `submit-relation-shape`。
47
47
  - **DATE(3) 字段的 `options.format` 必须是平台大写枚举**(`YYYY-MM-DD`、`YYYY-MM-DD HH:mm:ss`、`YYYY年MM月DD日`…),**不是 Java 的 `yyyy-MM-dd`**:平台 `DateBizField` 按字面比较,Java 写法能创建字段但**首次运行时 save 稳定 `50000`(`不支持的format`)**(2026-09-30 R5 实测)。`field.create`/`intent` 会就地拒绝非枚举值。
48
48
 
49
- - **多选关联(11) 的写入源码依据**(1.1.25 实机 jar):`WorkSheetMultiBizField.contentFromClient` 只接受 `String` 或 `Map`,其它类型直接 `AppAsserts.isTrue(false, "Incorrect value type, should be string or map.")` → 表现为 50000;`contentToDbValue()` 返回 `map.get("id")`,而读回时按 `";"` 切分(`MULTI_WORK_SHEET_SPILT`)并组装 `items` 数组。所以**数组是读写不对称的读侧形状,不能拿来写**;分号串才是写侧形状。实测三种形状:`"<id>"`/`"<id>;<id>"` 成功、`["<id>"]` 50000。
49
+ - **多选关联(11) 写入形状(1.1.25 jar 实证)**:`contentFromClient` 只收 String/Map,数组直接 50000;读回按 `;` 切分组装 items——数组是读侧形状不能写,分号串 `"<id>;<id>"` 才是写侧。实测:单/分号串成功、数组 50000。
50
50
 
51
51
  ## 业务规则与流程事件(源码实证,1.1.25)
52
52
 
53
- - **`Create` 的触发判定是「旧状态→新状态」**:旧 `DRAFT` 且新 `PROCESSING` 或 `COMPLETED`(`DomainServiceImpl#getDefaultRuleCode`)。所以无流程模型一调 `data.submit`(→COMPLETED)**和流程表单提交(DRAFT→PROCESSING)都触发 Create**;`data.save` 存草稿不触发。不要因为"目标表没变化"就断言规则没触发。
53
+ - **Create 触发=旧 DRAFT→新 PROCESSING/COMPLETED**(无流程 submit 和流程表单提交都触发;data.save 草稿不触发)——别因目标表没变就断言规则没跑。
54
54
  - **副作用缺失归因三步**:先查执行记录(`h_log_biz_service` 按服务/方法/时间,或容器日志)→ ①无调用=规则/触发问题 ②有调用但失败=参数/权限/业务异常 ③调用成功但不可见=视图投影/事务未提交/查询条件。禁止只看目标表。
55
55
  - **BIZ_ACTION 判成功只看传输**:REST 适配器解析响应后直接返回,**不校验 `errcode`**。扩展接口回 `{"errcode":50000}` + HTTP 200 会被当成节点成功(日志 `biz action node execute successful`)。要阻断必须走表单 `onValidate` 或数据规则,不能靠业务返回码。
56
56
  - **`rule.run`/自定义规则不回写出参**(r26 实证 2 轮):出参合并只发生在预置 Update 规则的执行上下文;`rule.run` 手动触发与自定义规则不把服务出参写回本模型——出参落表要走「BIZ_ACTION 节点 outputMappings + 数据事件触发」,验收用 data.load 看字段而非 rule.run 回执。
57
57
  - **出参回写三前提(r28b 8 组实验定案,单点总纲)**:① 响应 JSON 根级平铺出参键 ② 预置 Create/Update 规则上下文 ③ data.submit/data.update 触发(data.save 不触发)。三者缺一出参恒 null 且节点日志仍 successful——外部接口回信封的(`{code,message,data:[...]}`),先落平铺整形端点(平台扩展)再配 outputMappings,不要反复换规则形状试。
58
58
  - **布尔出参映射到文本字段 → ClassCastException 50000**(r28b):LOGICAL 出参映射 SHORT_TEXT 即炸——布尔只映射 LOGICAL 字段,落文本先在扩展侧转字符串。
59
- - **审批提交的 formData 带子表数组 = 追加行**(r28b 实证):approval submit 的 formData 里带子表数组会被平台当「追加行」处理,审批回执只回传主表字段——审批链路里要改子表行,用 data.update 单独写,不要塞进 formData。
59
+ - **审批 formData 带子表数组=追加行(r28b)**:审批链路要改子表行用 data.update 单独写,别塞进 formData(回执只回主表)。
60
60
  - **workItemId 不在 instanceActivity 里**(r28b 实证):流程实例活动查询不返回 workItemId;办理待办要的 workItemId 从 `search_workitems?state=PENDING` 的 `content[].id` 取。
61
- - **模型创建/删除非原子,会有半态残留**(r28b 实证 ~20min):model.create 在表单层撞错(如「表单已存在」)时 schema 可能已半落库——refused 后换名重试前先 model.list 核对半态并清理;同理模型删除后平台读面不可见但库里可能留僵尸行(引擎报 `appFunction not exist` 就是它在作祟),迁移/复用编码前先核对。
61
+ - **模型创建/删除非原子有半态(r28b)**:create 撞错可能半落库——重试前 model.list 核对;删除后可留僵尸行(appFunction not exist 元凶)。
62
62
  - **流程事件处理器吞异常**:`start`/`end`/`cancel`/`activate` 中的 BIZ_ACTION 抛异常时,`InstanceContext#processingBusiness` 只记 `processingBusiness error.` 并返回失败结果,**流程继续**。事件处理器不能做"提交前拦截"。
63
63
  - **禁写当前单据**:BIZ_ACTION 是 HTTP 自调用,外层事务已持行锁;同事务更新当前单据会等到 15s 超时并整条回滚。副作用写独立表;需要留痕用独立事务/异步重建 `RequestContext`。
64
64
 
@@ -193,7 +193,7 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
193
193
  - **`transferDepartment` 迁人不迁作用域**(r33 真机):换部门后 `role.userScope` 仍指旧部门——迁移后复核成员作用域。
194
194
  - **`user.delete` 是软删**(r33 真机):回 `deleted:true` 但 `user.info` 仍 `exists:true`(DISABLE)——验证口径用列表/状态,别称物理清除。
195
195
  - **「名称重复 200001」≠ 失败**(r33 真机):多为首次已建成、重发才撞重复——先读回确认首建结果再决定。
196
- - **删了的东西≠删干净(孤儿三连,1101 三例)**:①删模型留收藏/h_biz_* 行→日志 appFunction not exist;②规则死服务 A1→submit 500015;③流程模板删后绑定残留→getWorkflow 50000。都是报错在别处——先查孤儿引用。
196
+ - **删了的东西≠删干净(孤儿三连)**:①删模型留收藏/h_biz_* 行;②规则死服务 A1→submit 500015;③流程模板删后绑定残留→getWorkflow 50000。报错在别处——先查孤儿引用。
197
197
  - **两套条件 DSL(r38/r39)**:listview `conditions`={propertyCode, filterType, defaultValue};data.list `filter`={op, field, value}。互抄必错。
198
198
  - **表单读回以控件清单为准(r40)**:`published` 布尔会抖动、draftViewJson 可含悬空引用(rebuild 自清)——判定依据=publishedControlCodes 内容;configure_form 回执已点名 missingFieldCodes。
199
199
  - **submit 被拒仍可能已落草稿(r40)**:许可证/权限拒绝可能拒答前已写草稿——拒后对账 total,残留单清。
@@ -202,6 +202,10 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
202
202
  - **data.list 选项列投影可为 null(r40/r41)**:`all:true`/queryCode 下 RADIO 读 null,`data.load`/`filter` 才有值——验证选项字段以 data.load 为权威。
203
203
  - **流程绑定行不可 data.save 直改(r42)**:COMPLETED 行直改打回 PROCESSING、值不落、可能岔行——走提交流程。连续 submit 撞限流 100005——批量先 workitem.list 取 workItemId。
204
204
  - **带流程模型 data.submit 必停审批位(r43)**:批量初始化滞留待办——流程加条件分支按单据类型直达结束。SEQNO 不回收。
205
+ - **硬校验三通道**:精确重复→REPEAT_CHECK(datarule type1/checkType5,`properties:[字段]`,服务端双通道拦,真机 800150006);跨字段→SUBMIT_CHECK(checkType2);区间重叠→在线 JS(只拦浏览器,REST 绕过;r45 修正:精确重复的正解是 REPEAT_CHECK 不是在线 JS)。
206
+ - **读回对象不能直接当写参(r46)**:listview.get 读回的条件含 null 与平台归一键,原样回灌 configure 被拒——读→写须清洗(null 删、空串补、枚举合法化)再全量回带。
207
+ - **form rebuild 不补流程节点权限(r46)**:新字段入 propertyPermissions 集合后各节点默认 visible=false——须 workflow.update --merge 逐节点补权限,否则流程页字段不可见却"看似完成"。
208
+ - **rule.run 绕过调度过滤(r45)**:手动触发不走 schedulerSetting 过滤——结果不能当过滤证据,可能污染标记字段(测完重置)。
205
209
  - **读回形状≠可写形状**(r38):`listview.get` 读回条件带 `null` 字段,原样回写被拒——读回是输出形态,可写面有自己的契约;null 归一(false/省略)或只回写要改的键。
206
210
  - **data.list 的 columns 收窄可能静默空**(r38):非视图列字段投影 null;甚至 columns+分页 total>0 而 items 0——判"无数据"先去 columns 复查或用 data.load。
207
211