@stalfh233/omc-cli 0.6.11 → 0.6.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.
Files changed (58) hide show
  1. package/coverage/m1-coverage-manifest-v1.json +2 -0
  2. package/dist/approval-flow.js +84 -3
  3. package/dist/batch-call.js +32 -2
  4. package/dist/bench/call-face-eval.js +2 -2
  5. package/dist/call.js +35 -2
  6. package/dist/capability-overview.js +21 -7
  7. package/dist/cli-contract.js +34 -4
  8. package/dist/cli-output.js +6 -2
  9. package/dist/cli.js +303 -69
  10. package/dist/describe-cache.js +8 -4
  11. package/dist/environment-policy.js +20 -0
  12. package/dist/field-families.js +62 -4
  13. package/dist/form-support.js +293 -6
  14. package/dist/goal-routes.js +137 -22
  15. package/dist/identity.js +63 -11
  16. package/dist/lanes/app-contract.js +4 -4
  17. package/dist/lanes/app.js +19 -1
  18. package/dist/lanes/dictionary-contract.js +16 -5
  19. package/dist/lanes/dictionary-coverage.js +1 -0
  20. package/dist/lanes/dictionary.js +200 -8
  21. package/dist/lanes/environment-contract.js +18 -6
  22. package/dist/lanes/environment.js +129 -16
  23. package/dist/lanes/field-contract.js +2 -1
  24. package/dist/lanes/field.js +707 -59
  25. package/dist/lanes/form.js +52 -6
  26. package/dist/lanes/list-view-contract.js +2 -2
  27. package/dist/lanes/list-view.js +98 -2
  28. package/dist/lanes/meta.js +36 -10
  29. package/dist/lanes/model-contract.js +7 -6
  30. package/dist/lanes/model.js +154 -10
  31. package/dist/lanes/organization.js +67 -7
  32. package/dist/lanes/runtime-data-contract.js +4 -2
  33. package/dist/lanes/runtime-data.js +395 -41
  34. package/dist/lanes/workflow-contract.js +2 -2
  35. package/dist/lanes/workflow.js +381 -11
  36. package/dist/model-scaffold.js +11 -3
  37. package/dist/path-projection.js +192 -32
  38. package/dist/read-shapes.js +31 -11
  39. package/dist/recipes/field.js +16 -3
  40. package/dist/recipes/form.js +2 -1
  41. package/dist/recipes/runtime-data.js +58 -8
  42. package/dist/recipes/workflow.js +86 -1
  43. package/dist/refusal-guidance.js +18 -6
  44. package/dist/rollback-spec.js +41 -2
  45. package/dist/rollback.js +49 -11
  46. package/dist/script.js +72 -12
  47. package/dist/skills.js +91 -3
  48. package/dist/tools.js +113 -1
  49. package/dist/wire.js +13 -1
  50. package/dist/write-lock.js +92 -4
  51. package/dist/write.js +129 -16
  52. package/package.json +5 -3
  53. package/skills/omc-business-configuration.md +1 -1
  54. package/skills/omc-capabilities.md +5 -4
  55. package/skills/omc-playbook.md +41 -0
  56. package/skills/omc-refusals.md +58 -6
  57. package/skills/omc-tool-selection.md +2 -0
  58. package/skills/omc-write-hazards.md +3 -2
@@ -19,6 +19,56 @@ import { MODEL_CREATE_BODY_DEFAULTS, MODEL_LANE_CONTRACT } from "./model-contrac
19
19
  import { flattenFolders } from "./folder.js";
20
20
  const MODEL_LANE = MODEL_LANE_CONTRACT.lane;
21
21
  const MODEL_TOOLS = MODEL_LANE_CONTRACT.tools;
22
+ /**
23
+ * r29a F12:`model.list` 的 diet 投影。默认 `compactReadback` 把 `models` 压成
24
+ * 「code|name」字符串,小结果又原样回对象——同一工具元素类型不稳定。
25
+ * 这里恒回 `{code,schemaCode,name,legacyString}` 对象。
26
+ */
27
+ function modelListCompact(result) {
28
+ const compact = {};
29
+ for (const [key, value] of Object.entries(result)) {
30
+ if (key === "models" && Array.isArray(value)) {
31
+ compact.models = value.map((entry) => {
32
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
33
+ const row = entry;
34
+ const code = String(row.code ?? row.schemaCode ?? "");
35
+ const name = row.name == null ? null : String(row.name);
36
+ return {
37
+ code,
38
+ schemaCode: String(row.schemaCode ?? code),
39
+ name,
40
+ legacyString: name ? `${code}|${name}` : code,
41
+ };
42
+ }
43
+ if (typeof entry === "string") {
44
+ const sep = entry.indexOf("|");
45
+ const code = sep >= 0 ? entry.slice(0, sep) : entry;
46
+ const name = sep >= 0 ? entry.slice(sep + 1) : null;
47
+ return { code, schemaCode: code, name, legacyString: entry };
48
+ }
49
+ return entry;
50
+ });
51
+ compact.modelsCount = value.length;
52
+ continue;
53
+ }
54
+ if (value !== null && typeof value === "object" && !Array.isArray(value)
55
+ && Object.keys(value).length <= 4
56
+ && Buffer.byteLength(JSON.stringify(value), "utf8") <= 200) {
57
+ compact[key] = value;
58
+ continue;
59
+ }
60
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) {
61
+ compact[key] = value;
62
+ continue;
63
+ }
64
+ if (Array.isArray(value)) {
65
+ compact[`${key}Count`] = value.length;
66
+ continue;
67
+ }
68
+ compact[key] = value;
69
+ }
70
+ return compact;
71
+ }
22
72
  /**
23
73
  * Pending work items for every workflow attached to this model.
24
74
  *
@@ -195,11 +245,34 @@ export function registerModelLane(tools) {
195
245
  applicationCode: z.string().optional().describe("应用 code;省略用配置/默认应用"),
196
246
  filter: z.string().optional().describe("子串过滤:code 或 name 含该词才保留"),
197
247
  search: z.string().optional().describe("filter 的别名(集合读统一关键字手感;org.role.list/service.list 也用 search)"),
248
+ // r29b F12:探针传 `query` 被静默忽略(仅 ignoredArgs 提示)。接受为 filter 别名,
249
+ // 与 search 同义;三者都给时以 filter > search > query 优先。
250
+ query: z.string().optional().describe("filter 的别名(与 search 同义;子串过滤 code/name)"),
198
251
  capability: capabilityArg,
199
252
  },
200
253
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
201
254
  const scope = await resolveApplicationScope(session, args.applicationCode);
202
255
  const applicationCode = scope.code;
256
+ // r29f F6:显式/配置的 applicationCode 若不在 app.list 里,旧路径会把拼写错误
257
+ // 伪装成「该应用没有模型」。先核对租户应用表,未命中即拒(与 discover 的
258
+ // applicationResolution.status 同构)。
259
+ const availableApps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data)
260
+ .map((row) => String(row.appCode ?? row.code ?? ""))
261
+ .filter((code) => code !== "");
262
+ if (availableApps.length > 0 && !availableApps.includes(applicationCode)) {
263
+ throw new ToolRefusalError({
264
+ status: "refused",
265
+ blocker: "app-not-found",
266
+ applicationCode,
267
+ applicationResolution: {
268
+ status: "unresolved",
269
+ source: scope.source,
270
+ requested: scope.requested ?? applicationCode,
271
+ available: availableApps.slice(0, 40),
272
+ },
273
+ message: `应用「${applicationCode}」不在租户 app.list 里(不是「该应用没有模型」)。可用:${availableApps.slice(0, 12).join("、")}${availableApps.length > 12 ? " 等" : ""};先 app.list 取正确 applicationCode。`,
274
+ });
275
+ }
203
276
  const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
204
277
  const all = tree
205
278
  .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code)
@@ -207,7 +280,7 @@ export function registerModelLane(tools) {
207
280
  // the tree row's key. Echo both so `--jq 'models[].schemaCode'` works
208
281
  // (r15 P6: the alias was missing and the path missed).
209
282
  .map((row) => ({ code: row.code, schemaCode: row.code, name: row.name ?? row.text ?? null }));
210
- const needle = (args.filter ?? args.search)?.trim();
283
+ const needle = (args.filter ?? args.search ?? args.query)?.trim();
211
284
  const models = needle ? all.filter((model) => String(model.code).includes(needle) || String(model.name ?? "").includes(needle)) : all;
212
285
  // `filterApplied` disambiguates an empty result: it distinguishes "the
213
286
  // filter ran and matched nothing" from "the filter was not applied"
@@ -217,7 +290,7 @@ export function registerModelLane(tools) {
217
290
  // 1-of-N, so `count: 0` must never read as tenant-wide absence.
218
291
  return {
219
292
  applicationCode,
220
- applicationResolution: applicationResolutionOf(scope),
293
+ applicationResolution: { status: "resolved", ...applicationResolutionOf(scope) },
221
294
  count: models.length,
222
295
  totalBeforeFilter: all.length,
223
296
  filterApplied: needle !== undefined && needle !== "",
@@ -226,7 +299,13 @@ export function registerModelLane(tools) {
226
299
  models,
227
300
  ...(models.length === 0 ? { hint: applicationScopeHint(scope, "模型") } : {}),
228
301
  };
229
- }, { target: `model.list:${args.applicationCode ?? ""}:${args.filter ?? ""}` }),
302
+ }, {
303
+ target: `model.list:${args.applicationCode ?? ""}:${args.filter ?? args.search ?? args.query ?? ""}`,
304
+ // r29a F12:默认 compactReadback 把 models 压成「code|name」字符串,小应用
305
+ // 不过 diet 门仍回对象 → 同一工具元素类型不稳定。自定义 compact 恒回对象,
306
+ // 并附 legacyString 兼容旧解析。
307
+ compact: (result) => modelListCompact(result),
308
+ }),
230
309
  }));
231
310
  const siblings = tools;
232
311
  tools.push(defineTool({
@@ -433,6 +512,8 @@ export function registerModelLane(tools) {
433
512
  .describe("模型所属应用 code(应用包,不是目录)。省略用配置默认应用;folder.* 的 appCode 别名本工具不认,请用 applicationCode。"),
434
513
  modelType: z.enum(["LIST", "TREE"]).default("LIST"),
435
514
  },
515
+ // r29a F6:dry-run 必须做与实跑同样的 duplicate 探测,否则会假绿灯。
516
+ dryRunPayload: modelCreateDryRun,
436
517
  execute: async (session, args) => {
437
518
  const applicationCode = await resolveApplicationCode(session, args.applicationCode);
438
519
  const existing = await resolveModelTreeNode(session, args.code, args.applicationCode);
@@ -540,6 +621,48 @@ export function registerModelLane(tools) {
540
621
  },
541
622
  rollback: modelRollbackSpec("model.create"),
542
623
  });
624
+ /**
625
+ * r29a F6:model.create 的 session-aware dry-run。与实跑同一套 duplicate /
626
+ * orphan 探测(只读),命中时 wouldChange:false + blocker,避免假绿灯。
627
+ */
628
+ async function modelCreateDryRun(session, args) {
629
+ const existing = await resolveModelTreeNode(session, args.code, args.applicationCode);
630
+ if (existing.node) {
631
+ return {
632
+ wouldChange: false,
633
+ blocker: "model-create-duplicate",
634
+ schemaCode: args.code,
635
+ applicationCode: existing.applicationCode,
636
+ message: `模型 ${args.code} 已存在于应用 ${existing.applicationCode} 的应用树中;create 拒绝重复 POST,不能把既有模型证明为本次创建结果`,
637
+ };
638
+ }
639
+ try {
640
+ const record = await readModelRecord(session, args.code);
641
+ if (record !== null && typeof record === "object") {
642
+ return {
643
+ wouldChange: false,
644
+ blocker: "model-create-duplicate-orphan",
645
+ schemaCode: args.code,
646
+ applicationCode: args.applicationCode ?? null,
647
+ nextCommand: `omc call model.delete --env <env> --args '{"schemaCode":"${args.code}"}'`,
648
+ message: `模型 ${args.code} 在册(bizmodels/get_all 可读)但不在应用树中。**先跑一次 model.delete**——它现在会把两种成因分开:` +
649
+ `① 读缓存滞后(记录其实已删)→ 回 already-absent/stale-record-cache,随后可直接 create;` +
650
+ `② 真孤儿(删除级联中断留下的残骸)→ 回 model-orphaned-record 并给平台侧出路(回收站物理删除 / 换 code),因为 8.6 平台无法删除没有 AppFunction 行的业务模型。` +
651
+ `不要重复 create(会再造一条孤儿),也不要只信这一条拒绝信息。`,
652
+ };
653
+ }
654
+ }
655
+ catch {
656
+ // Record-face read failure must not be read as absence; dry-run proceeds.
657
+ }
658
+ const applicationCode = await resolveApplicationCode(session, args.applicationCode).catch(() => args.applicationCode ?? null);
659
+ return {
660
+ wouldChange: true,
661
+ schemaCode: args.code,
662
+ applicationCode,
663
+ summary: `即将创建模型 ${args.name ?? args.code}(${args.code})${args.modelType === "TREE" ? ",树型" : ",列表型"}${applicationCode ? `(应用 ${applicationCode})` : ""}`,
664
+ };
665
+ }
543
666
  /** model.delete writeTool extra arg: a session-aware dry-run that scans inbound references (no write). */
544
667
  async function modelDeleteDryRun(session, args) {
545
668
  const record = await (async () => {
@@ -620,8 +743,9 @@ export function registerModelLane(tools) {
620
743
  const applicationCode = resolved.applicationCode;
621
744
  const treeRows = async () => applicationCode ? flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data)) : [];
622
745
  if (!node) {
746
+ // r30a F22: mark noop so write.ts skips the rollback journal line.
623
747
  if (record === null)
624
- return { performed: { skipped: "already-absent" }, readback: { absent: true } };
748
+ return { performed: { skipped: "already-absent", noop: true }, readback: { absent: true } };
625
749
  // r12 (2026-10-24): "record readable but no tree node" has TWO causes and
626
750
  // the caller could not tell them apart — create said "go fix parentId",
627
751
  // model.move said "there is no node", model.delete said "no node means
@@ -642,7 +766,8 @@ export function registerModelLane(tools) {
642
766
  }
643
767
  if (stillPresent === null) {
644
768
  return {
645
- performed: { skipped: "stale-record-cache" },
769
+ // r30a F22: noop — do not journal a phantom delete.
770
+ performed: { skipped: "stale-record-cache", noop: true },
646
771
  readback: {
647
772
  absent: true,
648
773
  note: "业务模型记录读取面(bizmodels/get_all)先前仍返回该 code,复查后已消失——这是读缓存滞后的残留,模型实际已删除。若刚跑过 model.create 又被拒「已在册」,现在可以重试。",
@@ -950,6 +1075,10 @@ export function registerModelLane(tools) {
950
1075
  description: MODEL_TOOLS["model.update"].description,
951
1076
  inputSchema: {
952
1077
  schemaCode: z.string().min(1),
1078
+ // Accept+refuse: callers habitually send `code` thinking they can rename
1079
+ // the model identity. Keep it in schema so unknown-key suggestion does
1080
+ // NOT redirect them to schemaCode (schemaCode == code).
1081
+ code: z.string().min(1).optional().describe("恒拒(model-code-immutable):模型 code 即 schemaCode,创建后不可改;要换 code 请新建模型并迁移数据"),
953
1082
  name: z.string().min(1).optional().describe("新显示名(bizmodels/update 补丁语义,读回 get_all + 应用树节点)"),
954
1083
  modelType: z.enum(["LIST", "TREE"]).optional().describe("恒拒(model-type-immutable):模型类型创建时固化,bizmodels/update 只更新菜单节点显示"),
955
1084
  },
@@ -1003,8 +1132,16 @@ export function registerModelLane(tools) {
1003
1132
  };
1004
1133
  },
1005
1134
  preflight: (args) => {
1006
- // `omc call` bypasses zod, so an extra modelType key still reaches here.
1135
+ // `omc call` bypasses zod, so an extra modelType/code key still reaches here.
1007
1136
  const raw = args;
1137
+ // schemaCode == code:传 code 想改模型标识时,绝不能建议「改传 schemaCode」。
1138
+ if (raw.code !== undefined)
1139
+ throw new ToolRefusalError({
1140
+ status: "refused",
1141
+ blocker: "model-code-immutable",
1142
+ schemaCode: raw.schemaCode,
1143
+ message: "模型 code 是业务数据的标识(规则/表单/视图/数据都挂在 schemaCode 上),不可原地修改;要换 code 请新建模型并迁移数据",
1144
+ });
1008
1145
  // Probe evidence (yunshu ledger + ticket 09): bizmodels/update only
1009
1146
  // updates the app-menu node display — the underlying schema (type, tree
1010
1147
  // system fields) is FIXED at create. A modelType patch here would look
@@ -1024,17 +1161,21 @@ export function registerModelLane(tools) {
1024
1161
  name: "model.move",
1025
1162
  lane: MODEL_LANE,
1026
1163
  scope: "move a model's app-tree node to another folder (bizmodels/update parentId)",
1027
- description: "把模型移动到指定应用目录(分组):`POST bizmodels/update` 的 `parentId`(与 model.update 同一端点,AppFunctionController 补丁语义:bizSchemaCode/id/name/parentId 全量必填,工具从应用树解析)。`folderId` 必须是目录节点 id(`folder.tree` 的 id),**不能直接给目录名**——重名目录会定位错。移动后按应用树回读 `parentId/path` 确认;模型的 schema、字段、表单、视图都不动,只换菜单节点归属。目标目录不存在时拒绝 `folder-not-found` 并列出目录树。",
1164
+ description: "把模型在**同一应用内**移动到指定目录(分组):`POST bizmodels/update` 的 `parentId`(与 model.update 同一端点,AppFunctionController 补丁语义:bizSchemaCode/id/name/parentId 全量必填,工具从应用树解析)。" +
1165
+ "`folderId` 必须是目录节点 id(`folder.tree` 的 id),**不能直接给目录名**——重名目录会定位错。" +
1166
+ "移动后按应用树回读 `parentId/path` 确认;模型的 schema、字段、表单、视图都不动,只换菜单节点归属。" +
1167
+ "**模型不能跨应用移动**——目标目录必须与模型同应用;要在目标应用得到同名模型,请 `model.create --applicationCode <目标>`(换 code,或先 `model.delete` 源再同 code 重建)。" +
1168
+ "目标目录不存在时拒绝 `folder-not-found` 并列出目录树;目录属于其它应用时拒绝 `folder-other-application`。",
1028
1169
  inputSchema: {
1029
1170
  schemaCode: z.string().min(1),
1030
1171
  folderId: z
1031
1172
  .string()
1032
1173
  .min(1)
1033
- .describe("目标目录 id(必须是 folder.tree 里 type=Folder 的 id;顶层目录也是真实 id,不要传空或目录名)"),
1174
+ .describe("目标目录 id(必须是 folder.tree 里 type=Folder 的 id;顶层目录也是真实 id,不要传空或目录名;且必须与模型同应用)"),
1034
1175
  applicationCode: z
1035
1176
  .string()
1036
1177
  .optional()
1037
- .describe("模型所在应用 code(应用包,跨应用时显式给);folder.* 的 appCode 别名本工具不认。"),
1178
+ .describe("模型所在应用 code(应用包)。用于定位源模型节点;**不支持跨应用移动**——目标目录必须同应用。folder.* 的 appCode 别名本工具不认。"),
1038
1179
  },
1039
1180
  execute: async (session, args) => {
1040
1181
  const resolved = await resolveModelTreeNode(session, args.schemaCode, args.applicationCode);
@@ -1071,8 +1212,11 @@ export function registerModelLane(tools) {
1071
1212
  applicationCode: appCode,
1072
1213
  ...(otherApp ? { folderApplicationCode: otherApp } : {}),
1073
1214
  available: tree.filter((row) => row.type === "Folder").map((row) => ({ id: row.id, name: row.name })).slice(0, 40),
1215
+ // r29a F5:旧建议「先在源应用建同名目录再移动」永远到不了目标应用。
1074
1216
  message: otherApp
1075
- ? `目录 ${args.folderId} 属于应用 ${otherApp},而模型 ${args.schemaCode} 在应用 ${appCode}——模型的目录必须同应用;跨应用请先在 ${appCode} 建同名目录再移动`
1217
+ ? `目录 ${args.folderId} 属于应用 ${otherApp},而模型 ${args.schemaCode} 在应用 ${appCode}——模型不能跨应用移动。` +
1218
+ `要在 ${otherApp} 得到同名模型,请 \`model.create --applicationCode ${otherApp}\`(换 code,或先 \`model.delete\` 源再同 code 重建);` +
1219
+ `同应用内换目录请传 ${appCode} 下 folder.tree 的目录 id。`
1076
1220
  : `应用 ${appCode} 没有目录 ${args.folderId}——folderId 必须用 folder.tree 的目录节点 id(不是目录名)`,
1077
1221
  });
1078
1222
  }
@@ -8,6 +8,43 @@ import { ENDPOINTS } from "../endpoints.js";
8
8
  import { environmentArg, capabilityArg } from "../args.js";
9
9
  import { LANES } from "./lane-ids.js";
10
10
  import { collectOrgDepartments } from "./org-tree.js";
11
+ import { resolvePermissionActor } from "../identity.js";
12
+ /** Normalize get_ouscope rows; empty / null / non-array → [] (r30d F3). */
13
+ function decodeOuScopeRows(data) {
14
+ if (data === null || data === undefined)
15
+ return [];
16
+ if (Array.isArray(data) && data.length === 0)
17
+ return [];
18
+ try {
19
+ return wireRows(data);
20
+ }
21
+ catch (error) {
22
+ // Non-member / no-scope answers null or a non-array — that is a legitimate
23
+ // empty set, not a wire bug (r30d F3). Only rethrow when the payload looks
24
+ // like a real envelope with non-row content.
25
+ if (error instanceof WireError && error.kind === "malformed-envelope")
26
+ return [];
27
+ throw error;
28
+ }
29
+ }
30
+ /**
31
+ * Structured scope face (r30d F4): never echo the caller's includeChildren
32
+ * guess. Platform get_ouscope does not return an includeChildren bit, so we
33
+ * surface that honestly instead of inventing one.
34
+ */
35
+ function projectOuScopeRow(row) {
36
+ const deptId = row.id === undefined || row.id === null ? null : String(row.id);
37
+ const deptPath = row.deptPath === undefined || row.deptPath === null ? null : String(row.deptPath);
38
+ return {
39
+ id: deptId,
40
+ deptId,
41
+ name: row.name === undefined || row.name === null ? null : String(row.name),
42
+ type: row.type === undefined || row.type === null ? null : String(row.type),
43
+ deptPath,
44
+ includeChildren: "unknown(平台不返回)",
45
+ source: "configured",
46
+ };
47
+ }
11
48
  /**
12
49
  * Organization WRITE faces (8.6.41 customer transcript, 2026-10-21).
13
50
  *
@@ -384,7 +421,7 @@ export function registerOrganizationLane(tools) {
384
421
  kind: "read",
385
422
  lane: LANE,
386
423
  scope: SCOPES.userScope,
387
- description: "读成员的管理范围(`GET organization/role/get_ouscope`)。**不是枚举档位**(本人/本部门/全部这类不存在):scopes 是一组部门节点 `{id,name,type:\"1\",deptPath}` 的自由列表。`type:\"1\"`=部门节点;update_ouscope 是覆盖语义,改前先读。**roleId 与 roleCode 二者其一**(与写面对齐;都给且不一致则拒绝)。",
424
+ description: "读成员的管理范围(`GET organization/role/get_ouscope`)。**不是枚举档位**:scopes=`{deptId,name,type:\"1\",deptPath,includeChildren,source}`。`includeChildren` 平台不返回(`unknown`);`source:\"configured\"`=配置值、不证明生效。非成员/无范围→`{count:0,scopes:[]}`(合法空集,非 wire 错误)。**roleId/roleCode 二选一**。",
388
425
  inputSchema: {
389
426
  environment: environmentArg,
390
427
  roleId: z.string().min(1).optional().describe("角色 id(32 位 hex);与 roleCode 二选一"),
@@ -398,8 +435,19 @@ export function registerOrganizationLane(tools) {
398
435
  path: ENDPOINTS.organizationRoleGetOuScope,
399
436
  query: { ...orgQuery(session), roleId, userId: String(args.userId) },
400
437
  })).data;
401
- const scopes = wireRows(data).map((row) => ({ id: row.id ?? null, name: row.name ?? null, type: row.type ?? null, deptPath: row.deptPath ?? null }));
402
- return { roleId, userId: String(args.userId), count: scopes.length, scopes };
438
+ const scopes = decodeOuScopeRows(data).map(projectOuScopeRow);
439
+ const actor = await resolvePermissionActor(session);
440
+ return {
441
+ roleId,
442
+ userId: String(args.userId),
443
+ count: scopes.length,
444
+ scopes,
445
+ readback: "config",
446
+ note: scopes.length === 0
447
+ ? "该用户不在本角色或无管理范围(合法空集;不是读失败)"
448
+ : "本工具只回配置值(source:configured),不证明已参与 data.list/listview 过滤;includeChildren 平台不返回。",
449
+ actor,
450
+ };
403
451
  }),
404
452
  }));
405
453
  writeTool({
@@ -1216,7 +1264,7 @@ export function registerOrganizationLane(tools) {
1216
1264
  },
1217
1265
  execute: async (session, args) => {
1218
1266
  const roleId = await resolveRoleRef(session, { roleId: args.roleId, roleCode: args.roleCode }, "org.role.setUserScope");
1219
- const before = wireRows((await wireCall(session, {
1267
+ const before = decodeOuScopeRows((await wireCall(session, {
1220
1268
  path: ENDPOINTS.organizationRoleGetOuScope,
1221
1269
  query: { ...orgQuery(session), roleId, userId: args.userId },
1222
1270
  })).data);
@@ -1236,13 +1284,25 @@ export function registerOrganizationLane(tools) {
1236
1284
  query: orgQuery(session),
1237
1285
  body: { roleId, userId: args.userId, unitType: 3, sortKey: 999, ouScopeList: merged },
1238
1286
  });
1239
- const after = wireRows((await wireCall(session, {
1287
+ const afterRows = decodeOuScopeRows((await wireCall(session, {
1240
1288
  path: ENDPOINTS.organizationRoleGetOuScope,
1241
1289
  query: { ...orgQuery(session), roleId, userId: args.userId },
1242
- })).data).map((row) => ({ id: row.id ?? null, name: row.name ?? null, type: row.type ?? null, deptPath: row.deptPath ?? null }));
1290
+ })).data);
1291
+ const after = afterRows.map(projectOuScopeRow);
1292
+ const actor = await resolvePermissionActor(session);
1243
1293
  return {
1244
1294
  performed: { errcode: 0 },
1245
- readback: { roleId, userId: args.userId, mode: args.mode ?? "merge", before: before.length, after: after.length, scopes: after, ...(removed.length > 0 ? { removedScopeIds: removed } : {}) },
1295
+ readback: {
1296
+ roleId,
1297
+ userId: args.userId,
1298
+ mode: args.mode ?? "merge",
1299
+ before: before.length,
1300
+ after: after.length,
1301
+ scopes: after,
1302
+ ...(removed.length > 0 ? { removedScopeIds: removed } : {}),
1303
+ readback: "config",
1304
+ actor,
1305
+ },
1246
1306
  };
1247
1307
  },
1248
1308
  });
@@ -15,6 +15,7 @@ export function dataUpdateDescription() {
15
15
  `${wire.updateMergeRule}。`,
16
16
  `缺席保护:${wire.updateAbsentGuard}。`,
17
17
  `子表行:${wire.childRowStatusNote}`,
18
+ "CHILD_TABLE 字段传对象数组:既有行带 id、追加行 rowStatus:\"Added\"、删除行 {id,rowStatus:\"Deleted\"}(只给 id);**少传行不会删除**(见 --example child-rows-upsert)。",
18
19
  `计算字段:${wire.formulaFieldNote}`,
19
20
  "成功后以 form/load 权威读回变更字段;审批门/生产拒绝/租约与其他 M2 写一致(governedWrite)。",
20
21
  ]);
@@ -25,6 +26,7 @@ export function dataSaveDescription() {
25
26
  `带可选 workflowCode 时按门户暂存形状创建流程草稿:${wire.workflowDraftSave}。`,
26
27
  "formData 禁止携带 id(带 id 的就地发起在 8.6.26 无 REST 面,会被拒绝;既有无实例草稿请改用两段式或 data.submit 的 workItemId 审批链)。",
27
28
  "关联值形状(live 2026-09-24):**单选关联(propertyType 9)传 id 字符串或 { id };传数组会 50000**;多选关联(11)才用数组。**人员/组织(50/51/60/61)传选择对象数组 `[{\"id\":\"<id>\",\"type\":3,\"name\":\"<名>\"}]`——`type` 必填**(BizField.parseFromClient 把 `type` 强转 Integer 并 intValue(),缺 `type` 稳定 50000);`type` 取 UnitType 索引:用户=3、部门=1。字段 `mappings` 由表单运行时在用户选择时套用,**服务端 data.save 不会自动回填映射目标字段**——API 侧调用需自行写入被映射字段,否则读回为空(不要误判为映射配置失败)。",
29
+ "CHILD_TABLE 字段传对象数组(行键见 data.update --example child-rows-upsert);新建时可省略行 id(工具 stamp Added)。",
28
30
  `不带 workflowCode 走纯表单保存(新行 DRAFT);formType 语义与 replayToken 门见 data.submit 契约(${wire.replayTokenErrcode})。`,
29
31
  "根因提示(环境相关,ticket 78 观测):**某些环境**里模型所在应用未发布时运行时写稳定 50000(同调用在已发布应用成功),工具会反推并拒 app-not-published。注意该根因并非普遍规律——2026-09-19 实测另有一类环境未发布应用也能写运行时数据(详见 docs/issues/omc-cli-findings-2026-09-19.md B17)。遇 50000 时先用只读数据核对,不要仅凭“应用未发布”就放弃运行时验证。",
30
32
  `子表行:${wire.childRowStatusNote}`,
@@ -139,8 +141,8 @@ function desc(lead) {
139
141
  export function assertRuntimeDataContract() {
140
142
  assertLaneContract(RUNTIME_DATA_LANE_CONTRACT);
141
143
  assertDescriptionCarries(RUNTIME_DATA_LANE_CONTRACT, "data.list", ["queryCondition", "propertyType", "rawCondition", "projectionHint", "listview.configure", "data.load", "items[].fields"]);
142
- assertDescriptionCarries(RUNTIME_DATA_LANE_CONTRACT, "data.save", ["bizObjectId", "sheetCode", "bizsheet/get", "workflowCode", "sequenceStatus", "workItem", "propertyType 9", "50000", "mappings", "rowStatus", "DRAFT", "submit:true"]);
143
- assertDescriptionCarries(RUNTIME_DATA_LANE_CONTRACT, "data.update", ["runtime/form/save", "bizObjectId", "COMPLETED", "304001", "form/load", "rowStatus"]);
144
+ assertDescriptionCarries(RUNTIME_DATA_LANE_CONTRACT, "data.save", ["bizObjectId", "sheetCode", "bizsheet/get", "workflowCode", "sequenceStatus", "workItem", "propertyType 9", "50000", "mappings", "rowStatus", "DRAFT", "submit:true", "CHILD_TABLE"]);
145
+ assertDescriptionCarries(RUNTIME_DATA_LANE_CONTRACT, "data.update", ["runtime/form/save", "bizObjectId", "COMPLETED", "304001", "form/load", "rowStatus", "child-rows-upsert"]);
144
146
  assertDescriptionCarries(RUNTIME_DATA_LANE_CONTRACT, "data.delete", ["absence", "sheetCode"]);
145
147
  assertDescriptionCarries(RUNTIME_DATA_LANE_CONTRACT, "data.submit", ["getReplayToken", "replayToken", "bizObjectId", "objectId", "DRAFT", "COMPLETED", "10009", "100005", "NPE", "两段式", "finishStart=FALSE"]);
146
148
  if (!RUNTIME_DATA_WIRE.workflowDraftSave.includes("finishStart=FALSE"))