@stalfh233/omc-cli 0.6.7 → 0.6.11

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 (67) hide show
  1. package/README.md +2 -1
  2. package/dist/approval-flow.js +5 -3
  3. package/dist/bizservice-config.js +26 -10
  4. package/dist/call.js +47 -0
  5. package/dist/capability-overview.js +47 -24
  6. package/dist/cli-contract.js +73 -26
  7. package/dist/cli-output.js +41 -2
  8. package/dist/cli-stdout.js +74 -0
  9. package/dist/cli.js +135 -52
  10. package/dist/command-transport.js +8 -1
  11. package/dist/config-store.js +17 -2
  12. package/dist/discovery.js +62 -3
  13. package/dist/field-families.js +35 -6
  14. package/dist/goal-routes.js +118 -11
  15. package/dist/human-summary.js +11 -0
  16. package/dist/lanes/app-contract.js +12 -5
  17. package/dist/lanes/app.js +221 -134
  18. package/dist/lanes/apply-changes.js +2 -1
  19. package/dist/lanes/b2-registration-contract.js +3 -3
  20. package/dist/lanes/b2-registration.js +890 -693
  21. package/dist/lanes/businessrule.js +75 -51
  22. package/dist/lanes/custom-page.js +166 -115
  23. package/dist/lanes/datarule-contract.js +2 -2
  24. package/dist/lanes/datarule.js +238 -154
  25. package/dist/lanes/dictionary.js +253 -110
  26. package/dist/lanes/environment.js +57 -9
  27. package/dist/lanes/field.js +160 -107
  28. package/dist/lanes/folder-contract.js +7 -3
  29. package/dist/lanes/folder.js +249 -118
  30. package/dist/lanes/form-contract.js +1 -1
  31. package/dist/lanes/form.js +188 -64
  32. package/dist/lanes/list-view.js +405 -369
  33. package/dist/lanes/meta.js +31 -6
  34. package/dist/lanes/model-contract.js +1 -1
  35. package/dist/lanes/model.js +610 -512
  36. package/dist/lanes/online-js-contract.js +1 -1
  37. package/dist/lanes/online-js.js +160 -146
  38. package/dist/lanes/organization.js +905 -640
  39. package/dist/lanes/programming.js +274 -136
  40. package/dist/lanes/report-contract.js +2 -2
  41. package/dist/lanes/report.js +354 -198
  42. package/dist/lanes/rule-lifecycle.js +294 -140
  43. package/dist/lanes/runtime-data-contract.js +4 -4
  44. package/dist/lanes/runtime-data.js +869 -745
  45. package/dist/lanes/workflow-contract.js +3 -3
  46. package/dist/lanes/workflow.js +732 -605
  47. package/dist/model-scaffold.js +26 -5
  48. package/dist/path-projection.js +196 -14
  49. package/dist/plan.js +25 -0
  50. package/dist/programming-workflow.js +55 -3
  51. package/dist/read-shapes.js +146 -0
  52. package/dist/read.js +40 -9
  53. package/dist/recipes/businessrule.js +74 -24
  54. package/dist/recipes/dictionary.js +7 -4
  55. package/dist/recipes/form.js +46 -0
  56. package/dist/recipes/runtime-data.js +3 -2
  57. package/dist/refusal-guidance.js +29 -5
  58. package/dist/rule-support.js +14 -1
  59. package/dist/rule-when.js +2 -2
  60. package/dist/tool-types.js +9 -3
  61. package/dist/version-family-gaps.js +101 -0
  62. package/dist/write.js +2 -1
  63. package/package.json +6 -4
  64. package/skills/omc-glossary.md +13 -0
  65. package/skills/omc-refusals.md +13 -5
  66. package/skills/omc-tool-selection.md +2 -1
  67. package/skills/omc-write-hazards.md +5 -2
@@ -38,6 +38,23 @@ import { FOLDER_LANE_CONTRACT } from "./folder-contract.js";
38
38
  */
39
39
  const LANE = LANES.folder;
40
40
  const T = FOLDER_LANE_CONTRACT.tools;
41
+ /**
42
+ * Live 1.1.25 (r28d F2): platform `apppackage/create` seeds one top-level Folder
43
+ * whose `name_i18n.en` is the stable token `defaultGroup` (zh display is「默认分组」).
44
+ * Name alone is brittle — agents rename / locales differ — so mark by that en key.
45
+ */
46
+ export function isDefaultFolderNode(node) {
47
+ const raw = node.name_i18n;
48
+ if (typeof raw !== "string" || raw.length === 0)
49
+ return false;
50
+ try {
51
+ const parsed = JSON.parse(raw);
52
+ return String(parsed.en ?? "") === "defaultGroup";
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
41
58
  /** Flatten the nested app tree, keeping the path so a caller can tell WHICH "合同" it means. */
42
59
  /**
43
60
  * Flatten the nested app tree. Live 1.1.25 readback: `apppackage/trees?appCode=`
@@ -45,35 +62,76 @@ const T = FOLDER_LANE_CONTRACT.tools;
45
62
  * carrying its children — so a top-level folder is depth 0 and a child is
46
63
  * "父 / 子" at depth 1. Reading only the top array makes a freshly created child
47
64
  * folder look absent (the customer's first failure).
65
+ *
66
+ * Sibling order: Folder before other types, then by name (stable for tree
67
+ * presentation). Preorder walk is preserved across depths. BizModel nodes also
68
+ * get `displayPath` (`path + " [model]"`) so a same-named folder/model pair is
69
+ * distinguishable without changing the existing `path` contract (r28d F5).
48
70
  */
49
- function flattenFolders(rows, parentPath = []) {
71
+ export function flattenFolders(rows, parentPath = []) {
50
72
  const out = [];
51
- for (const raw of rows) {
52
- if (raw === null || typeof raw !== "object")
53
- continue;
54
- const node = raw;
73
+ const siblings = rows
74
+ .filter((raw) => raw !== null && typeof raw === "object")
75
+ .slice()
76
+ .sort((a, b) => {
77
+ const aFolder = String(a.type ?? "") === "Folder" ? 0 : 1;
78
+ const bFolder = String(b.type ?? "") === "Folder" ? 0 : 1;
79
+ if (aFolder !== bFolder)
80
+ return aFolder - bFolder;
81
+ return String(a.name ?? a.code ?? "").localeCompare(String(b.name ?? b.code ?? ""), "zh");
82
+ });
83
+ for (const node of siblings) {
55
84
  const name = String(node.name ?? node.code ?? "");
56
- const path = [...parentPath, name];
85
+ const pathParts = [...parentPath, name];
86
+ const path = pathParts.join(" / ");
87
+ const type = node.type ?? null;
88
+ const isDefault = type === "Folder" && isDefaultFolderNode(node);
57
89
  out.push({
58
90
  id: node.id ?? null,
59
- type: node.type ?? null,
91
+ type,
60
92
  code: node.code ?? null,
61
93
  name: node.name ?? null,
62
94
  parentId: node.parentId ?? null,
63
- depth: path.length - 1,
64
- path: path.join(" / "),
95
+ depth: pathParts.length - 1,
96
+ path,
97
+ ...(type === "BizModel" ? { displayPath: `${path} [model]` } : {}),
98
+ ...(isDefault ? { isDefault: true } : {}),
65
99
  });
66
100
  if (Array.isArray(node.children) && node.children.length > 0)
67
- out.push(...flattenFolders(node.children, path));
101
+ out.push(...flattenFolders(node.children, pathParts));
68
102
  }
69
103
  return out;
70
104
  }
71
105
  async function readTree(session, appCode) {
72
106
  return flattenFolders(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode } })).data));
73
107
  }
108
+ /** Shared by app.delete (r28d F8) so the non-empty refusal can list children with path. */
109
+ export async function readAppTree(session, appCode) {
110
+ return readTree(session, appCode);
111
+ }
74
112
  function folderRefusal(schemaCode, message, extra = {}) {
75
113
  throw new ToolRefusalError({ status: "refused", blocker: "folder-not-found", ...(schemaCode ? { applicationCode: schemaCode } : {}), ...extra, message });
76
114
  }
115
+ /**
116
+ * F6(r27):folder.* 与 report.* 的应用范围参数名历史上不统一
117
+ * (folder 用 applicationCode,report 用 appCode)。两边都接受,但两者都给且
118
+ * 不一致时拒绝,避免猜错应用。
119
+ */
120
+ function resolveFolderAppCode(args) {
121
+ const fromLong = typeof args.applicationCode === "string" ? args.applicationCode.trim() : "";
122
+ const fromShort = typeof args.appCode === "string" ? args.appCode.trim() : "";
123
+ if (fromLong && fromShort && fromLong !== fromShort) {
124
+ throw new ToolRefusalError({
125
+ status: "refused",
126
+ blocker: "app-code-conflict",
127
+ applicationCode: fromLong,
128
+ appCode: fromShort,
129
+ message: `applicationCode="${fromLong}" 与 appCode="${fromShort}" 不一致——本工具两个键是同一含义的别名,请只传一个,或传相同值`,
130
+ });
131
+ }
132
+ const chosen = fromLong || fromShort;
133
+ return chosen.length > 0 ? chosen : undefined;
134
+ }
77
135
  /**
78
136
  * Resolve a folder by id, code, or by full path ("合同管理 / 归档"). A bare name
79
137
  * that matches several folders is ambiguous — name them with their paths instead
@@ -96,8 +154,13 @@ function resolveFolder(rows, ref) {
96
154
  return byName[0];
97
155
  if (byName.length > 1)
98
156
  folderRefusal(undefined, `目录名「${ref}」不唯一(${byName.length} 个)——用完整 path 指定:${byName.map((row) => String(row.path)).join("、")}`);
99
- folderRefusal(undefined, `找不到目录「${ref}」——先 folder.tree 看 id/path;注意 create 省略 parentId 建的是顶层目录`, {
157
+ // r28i F9: report.create.objectId / custompage page id is NOT an AppFunction
158
+ // tree node id. When the id is absent from the tree, say so instead of only
159
+ // "folder-not-found" (which sends people back to folder.tree hunting).
160
+ folderRefusal(undefined, `找不到目录「${ref}」——先 folder.tree 看 id/path;注意 create 省略 parentId 建的是顶层目录。` +
161
+ `若此 id 来自 report.create 的 objectId/reportObjectId,那不是目录 id 也不是应用树节点 id(树节点 id≠报表对象 id)——删报表用 report.delete --args '{"code":"<code>"}';删自定义页用 custompage.delete;应用树节点 id 请 folder.tree 按 code 查`, {
100
162
  available: rows.filter((row) => row.type === "Folder").map((row) => ({ id: row.id, path: row.path })).slice(0, 40),
163
+ typeHint: "id-not-in-app-tree — if from report.create.objectId use report.delete by code; if AppFunction Report/CustomPage node use the matching delete tool",
101
164
  });
102
165
  }
103
166
  function folderTools(tools) {
@@ -107,133 +170,201 @@ function folderTools(tools) {
107
170
  kind: "read",
108
171
  lane: LANE,
109
172
  scope: T["folder.tree"].scope,
110
- description: "读取应用树(目录/分组 + 模型节点),**递归展平**并给出每个节点的 `path` 与 `depth`(平台 `apppackage/trees?appCode=` 返回的是嵌套 children,只看顶层会误判“目录没建成”——issue 2026-10-23 的真实失败)。`foldersOnly:true` 只回目录(type=Folder)。目录的 id 就是 folder.update/delete 与 model.move 的 `folderId/parentId`。",
173
+ description: "读取**应用树**(应用**内**目录/分组 + 模型节点;不是应用包本身——应用包用 app.*)。**递归展平**并给出每个节点的 `path`/`depth`(平台 `apppackage/trees?appCode=` 返回嵌套 children,只看顶层会误判“目录没建成”——issue 2026-10-23)。`foldersOnly:true` 只回目录(type=Folder)。目录 id 就是 folder.update/delete 与 model.move/model.create 的 `folderId/parentId`。不存在的应用回 `applicationExists:false` + 空树(仍 ok,不拒——与「存在但空」可区分)。平台自动建的默认分组节点带 `isDefault:true`(判据:name_i18n.en=defaultGroup)。",
111
174
  inputSchema: {
112
175
  environment: environmentArg,
113
- applicationCode: z.string().optional().describe("应用 code;省略用配置默认应用"),
176
+ applicationCode: z.string().optional().describe("应用 code;省略用配置默认应用(别名:appCode)"),
177
+ appCode: z.string().optional().describe("applicationCode 的别名(与 report.* 对齐);两者都给且不一致时报 app-code-conflict"),
114
178
  foldersOnly: z.boolean().optional().describe("只回目录节点(type=Folder)"),
115
179
  capability: capabilityArg,
116
180
  },
117
181
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
118
- const appCode = await resolveApplicationCode(session, args.applicationCode);
119
- const rows = await readTree(session, appCode);
182
+ const requested = resolveFolderAppCode(args);
183
+ const appCode = await resolveApplicationCode(session, requested);
184
+ // r28d F7: apppackage/trees answers [] for a missing app AND for an
185
+ // empty app. Distinguish with apppackage/list so a post-delete check
186
+ // is not forced through app.list by hand. Keep status=ok + empty
187
+ // nodes so existing empty-tree callers stay intact.
188
+ const apps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data);
189
+ const applicationExists = apps.some((row) => String(row.appCode ?? row.code ?? "") === appCode);
190
+ const rows = applicationExists ? await readTree(session, appCode) : [];
120
191
  const nodes = args.foldersOnly === true ? rows.filter((row) => row.type === "Folder") : rows;
121
192
  return {
122
193
  applicationCode: appCode,
194
+ applicationExists,
123
195
  nodeCount: nodes.length,
124
196
  folderCount: rows.filter((row) => row.type === "Folder").length,
125
197
  modelCount: rows.filter((row) => row.type === "BizModel").length,
126
198
  nodes,
127
- note: "type=Folder 是目录/分组;parentId 指向父目录(顶层目录的 parentId = 应用包 id)。嵌套 children 已展平,path 用「 / 」连接。",
199
+ note: "type=Folder 是应用内目录/分组(不是应用包);parentId 指向父目录(顶层目录的 parentId = 应用包 id)。嵌套 children 已展平,path 用「 / 」连接。" +
200
+ (applicationExists
201
+ ? " 平台自动建的默认分组带 isDefault:true(name_i18n.en=defaultGroup);模型节点另有 displayPath。"
202
+ : " applicationExists:false——应用不在 apppackage/list;空树不等于「空应用」。") +
203
+ " nodeCount/folderCount/modelCount 是全应用聚合,共享环境含其它会话资源;核对自己的资源请按 code 前缀过滤 nodes[],不要只比计数。",
128
204
  };
129
205
  }),
130
206
  }));
131
- writeTool(tools, "folder.create", LANE, T["folder.create"].scope, "新建应用目录(分组):`POST /api/app/folders/create`(AppFunctionController:105;`ApplicationFacadeImpl.createFolder:586` 强制 `type=Folder`,**省略 `parentId` 会挂到应用根**,即顶层目录)。给 `parentId` 才是子目录。写入后按 `path` 回读确认(树是嵌套的)。同名不等于重复:平台不校验重名,工具默认按「同父目录下同名」拒绝(`folder-duplicate`),要并列同名传 `allowDuplicate:true`。", {
132
- applicationCode: z.string().optional().describe("应用 code;省略用配置默认应用"),
133
- name: z.string().min(1).describe("目录显示名(平台校验非空、中文长度 ≤50)"),
134
- parentId: z.string().optional().describe("父目录 id;**省略=应用根(顶层目录)**。子目录必须显式给,值来自 folder.tree 的 id"),
135
- code: z.string().optional().describe("目录 code(可选;平台只校验 code 唯一,空则自动生成)"),
136
- name_i18n: z.string().optional().describe("多语言名(JSON 字符串,如 {\"zh-CN\":\"合同\",\"en\":\"Contract\"})"),
137
- allowDuplicate: z.boolean().optional().describe("允许同父目录下同名(默认拒绝,避免建出两个同名目录后无法用 path 定位)"),
138
- }, async (session, args) => {
139
- const appCode = await resolveApplicationCode(session, args.applicationCode);
140
- const before = await readTree(session, appCode);
141
- if (args.allowDuplicate !== true) {
142
- const siblings = before.filter((row) => String(row.parentId ?? "") === String(args.parentId ?? "") || (args.parentId === undefined && row.depth === 0));
143
- if (siblings.some((row) => row.type === "Folder" && String(row.name ?? "") === args.name))
144
- throw new ToolRefusalError({
145
- status: "refused",
146
- blocker: "folder-duplicate",
207
+ writeTool({
208
+ tools,
209
+ name: "folder.create",
210
+ lane: LANE,
211
+ scope: T["folder.create"].scope,
212
+ description: "新建应用目录(分组):`POST /api/app/folders/create`(AppFunctionController:105;`ApplicationFacadeImpl.createFolder:586` 强制 `type=Folder`,**省略 `parentId` 会挂到应用根**,即顶层目录)。给 `parentId` 才是子目录。写入后按 `path` 回读确认(树是嵌套的)。同名不等于重复:平台不校验重名,工具默认按「同父目录下同名」拒绝(`folder-duplicate`),要并列同名传 `allowDuplicate:true`。",
213
+ inputSchema: {
214
+ applicationCode: z.string().optional().describe("应用 code;省略用配置默认应用(别名:appCode)"),
215
+ appCode: z.string().optional().describe("applicationCode 的别名(与 report.* 对齐);两者都给且不一致时报 app-code-conflict"),
216
+ name: z.string().min(1).describe("目录显示名(平台校验非空、中文长度 ≤50)"),
217
+ parentId: z.string().optional().describe("父目录 id;**省略=应用根(顶层目录)**。子目录必须显式给,值来自 folder.tree 的 id"),
218
+ code: z.string().optional().describe("目录 code(可选;平台只校验 code 唯一,空则自动生成)"),
219
+ name_i18n: z.string().optional().describe("多语言名(JSON 字符串,如 {\"zh-CN\":\"合同\",\"en\":\"Contract\"})"),
220
+ allowDuplicate: z.boolean().optional().describe("允许同父目录下同名(默认拒绝,避免建出两个同名目录后无法用 path 定位)"),
221
+ },
222
+ execute: async (session, args) => {
223
+ const appCode = await resolveApplicationCode(session, resolveFolderAppCode(args));
224
+ const before = await readTree(session, appCode);
225
+ if (args.allowDuplicate !== true) {
226
+ const siblings = before.filter((row) => String(row.parentId ?? "") === String(args.parentId ?? "") || (args.parentId === undefined && row.depth === 0));
227
+ if (siblings.some((row) => row.type === "Folder" && String(row.name ?? "") === args.name))
228
+ throw new ToolRefusalError({
229
+ status: "refused",
230
+ blocker: "folder-duplicate",
231
+ applicationCode: appCode,
232
+ path: `${args.parentId ?? "(root)"} / ${args.name}`,
233
+ message: `同一层级已有同名目录「${args.name}」——平台允许重名,但重名后 folder.tree 的 path 无法唯一定位;确认要并列同名再传 allowDuplicate:true`,
234
+ });
235
+ }
236
+ const performed = await wireCall(session, {
237
+ path: ENDPOINTS.folderCreate,
238
+ method: "POST",
239
+ body: {
240
+ appCode,
241
+ name: args.name,
242
+ ...(args.parentId !== undefined ? { parentId: args.parentId } : {}),
243
+ ...(args.code !== undefined ? { code: args.code } : {}),
244
+ ...(args.name_i18n !== undefined ? { name_i18n: args.name_i18n } : {}),
245
+ },
246
+ });
247
+ const after = await readTree(session, appCode);
248
+ const created = after.filter((row) => row.type === "Folder" && String(row.name ?? "") === args.name && !before.some((old) => old.id === row.id));
249
+ return {
250
+ performed: { errcode: performed.errcode },
251
+ readback: {
147
252
  applicationCode: appCode,
148
- path: `${args.parentId ?? "(root)"} / ${args.name}`,
149
- message: `同一层级已有同名目录「${args.name}」——平台允许重名,但重名后 folder.tree 的 path 无法唯一定位;确认要并列同名再传 allowDuplicate:true`,
150
- });
151
- }
152
- const performed = await wireCall(session, {
153
- path: ENDPOINTS.folderCreate,
154
- method: "POST",
155
- body: {
156
- appCode,
157
- name: args.name,
158
- ...(args.parentId !== undefined ? { parentId: args.parentId } : {}),
159
- ...(args.code !== undefined ? { code: args.code } : {}),
160
- ...(args.name_i18n !== undefined ? { name_i18n: args.name_i18n } : {}),
161
- },
162
- });
163
- const after = await readTree(session, appCode);
164
- const created = after.filter((row) => row.type === "Folder" && String(row.name ?? "") === args.name && !before.some((old) => old.id === row.id));
165
- return {
166
- performed: { errcode: performed.errcode },
167
- readback: {
168
- applicationCode: appCode,
169
- present: created.length > 0,
170
- created: created.map((row) => ({ id: row.id, path: row.path, parentId: row.parentId })),
171
- folderCount: after.filter((row) => row.type === "Folder").length,
172
- ...(after.filter((row) => row.type === "Folder" && String(row.name ?? "") === args.name && String(row.parentId ?? "") === String(created[0]?.parentId ?? ""))
173
- .length > 1
174
- ? { warning: "同层级出现了多个同名目录(allowDuplicate 生效或既有同名);后续用 id 定位,不要用 path/name" }
175
- : {}),
176
- hint: created.length > 0 ? "把模型放进该目录用 model.move(parentId = 这里的 id)" : "回读未见新目录:树是嵌套的,但 folder.tree 已递归展平;若确实没有,检查应用 code",
177
- },
178
- };
179
- });
180
- writeTool(tools, "folder.update", LANE, T["folder.update"].scope, "重命名目录或把目录挂到另一个目录下:`POST /api/app/folders/update`(AppFunctionController:219;`updateFolder:612` 拒绝 `parentId === id`,`parentId` 为空时保留原父级)。**只改目录节点本身,不会移动目录里的模型**——模型移动用 `model.move`。", {
181
- applicationCode: z.string().optional(),
182
- folderId: z.string().min(1).describe("目录 id(folder.tree 的 id)"),
183
- name: z.string().min(1).optional().describe("新显示名"),
184
- name_i18n: z.string().optional(),
185
- parentId: z.string().min(1).optional().describe("新父目录 id(挂到根用应用包 id;不理解根 id 就省略此字段表示不改父级)"),
186
- }, async (session, args) => {
187
- const appCode = await resolveApplicationCode(session, args.applicationCode);
188
- const before = await readTree(session, appCode);
189
- const current = resolveFolder(before, args.folderId);
190
- if (args.name === undefined && args.parentId === undefined)
191
- throw new ToolRefusalError({ status: "refused", blocker: "folder-update-empty-patch", message: "没有可应用的补丁——至少给 name 或 parentId" });
192
- if (args.parentId !== undefined && args.parentId === args.folderId)
193
- throw new ToolRefusalError({ status: "refused", blocker: "folder-parent-self", message: "parentId 不能等于目录自身(平台 FOLDER_PARENTID_EQUALS_ID)" });
194
- const performed = await wireCall(session, {
195
- path: ENDPOINTS.folderUpdate,
196
- method: "POST",
197
- body: { id: args.folderId, name: args.name ?? current.name, ...(args.name_i18n !== undefined ? { name_i18n: args.name_i18n } : {}), ...(args.parentId !== undefined ? { parentId: args.parentId } : {}) },
198
- });
199
- const after = await readTree(session, appCode);
200
- const now = after.find((row) => String(row.id ?? "") === args.folderId);
201
- return {
202
- performed: { errcode: performed.errcode },
203
- readback: {
204
- folderId: args.folderId,
205
- present: now !== undefined,
206
- before: { name: current.name, parentId: current.parentId, path: current.path },
207
- after: now ? { name: now.name, parentId: now.parentId, path: now.path } : null,
208
- note: "只改了目录节点;目录内的模型不随目录移动(模型要动请用 model.move)",
209
- },
210
- };
253
+ present: created.length > 0,
254
+ created: created.map((row) => ({ id: row.id, path: row.path, parentId: row.parentId })),
255
+ folderCount: after.filter((row) => row.type === "Folder").length,
256
+ ...(after.filter((row) => row.type === "Folder" && String(row.name ?? "") === args.name && String(row.parentId ?? "") === String(created[0]?.parentId ?? ""))
257
+ .length > 1
258
+ ? { warning: "同层级出现了多个同名目录(allowDuplicate 生效或既有同名);后续用 id 定位,不要用 path/name" }
259
+ : {}),
260
+ hint: created.length > 0 ? "把模型放进该目录用 model.move(parentId = 这里的 id)" : "回读未见新目录:树是嵌套的,但 folder.tree 已递归展平;若确实没有,检查应用 code",
261
+ },
262
+ };
263
+ },
211
264
  });
212
- writeTool(tools, "folder.delete", LANE, T["folder.delete"].scope, "删除空目录:`POST /api/app/folders/delete?folderId=`(AppFunctionController:246)。**非递归**——目录里还有子目录或模型时平台回 `DIRECTORY_NOT_EMPTY`,工具在写前先查子节点并拒绝(`folder-not-empty`),避免半删。要清空请先 `model.move` 移走模型、删子目录。", {
213
- applicationCode: z.string().optional(),
214
- folderId: z.string().min(1),
215
- }, async (session, args) => {
216
- const appCode = await resolveApplicationCode(session, args.applicationCode);
217
- const before = await readTree(session, appCode);
218
- const current = resolveFolder(before, args.folderId);
219
- if (String(current.type ?? "") !== "Folder")
220
- throw new ToolRefusalError({ status: "refused", blocker: "folder-target-not-folder", message: `${args.folderId} 不是目录节点(type=${String(current.type)});删模型用 model.delete` });
221
- const children = before.filter((row) => String(row.parentId ?? "") === String(args.folderId));
222
- if (children.length > 0)
223
- throw new ToolRefusalError({
224
- status: "refused",
225
- blocker: "folder-not-empty",
226
- folderId: args.folderId,
227
- children: children.map((row) => ({ id: row.id, type: row.type, path: row.path })).slice(0, 40),
228
- message: `目录「${current.path}」还有 ${children.length} 个子节点——平台非递归删除会回 DIRECTORY_NOT_EMPTY。先 model.move 移走模型 / 删子目录`,
265
+ writeTool({
266
+ tools,
267
+ name: "folder.update",
268
+ lane: LANE,
269
+ scope: T["folder.update"].scope,
270
+ description: "重命名目录或把目录挂到另一个目录下:`POST /api/app/folders/update`(AppFunctionController:219;`updateFolder:612` 拒绝 `parentId === id`,`parentId` 为空时保留原父级)。**只改目录节点本身,不会移动目录里的模型**——模型移动用 `model.move`。",
271
+ inputSchema: {
272
+ applicationCode: z.string().optional().describe("应用 code;省略用配置默认应用(别名:appCode)"),
273
+ appCode: z.string().optional().describe("applicationCode 的别名(与 report.* 对齐);两者都给且不一致时报 app-code-conflict"),
274
+ folderId: z.string().min(1).describe("目录 id(folder.tree 的 id)"),
275
+ name: z.string().min(1).optional().describe("新显示名"),
276
+ name_i18n: z.string().optional(),
277
+ parentId: z.string().min(1).optional().describe("新父目录 id(挂到根用应用包 id;不理解根 id 就省略此字段表示不改父级)"),
278
+ },
279
+ execute: async (session, args) => {
280
+ const appCode = await resolveApplicationCode(session, resolveFolderAppCode(args));
281
+ const before = await readTree(session, appCode);
282
+ const current = resolveFolder(before, args.folderId);
283
+ if (args.name === undefined && args.parentId === undefined)
284
+ throw new ToolRefusalError({ status: "refused", blocker: "folder-update-empty-patch", message: "没有可应用的补丁——至少给 name 或 parentId" });
285
+ if (args.parentId !== undefined && args.parentId === args.folderId)
286
+ throw new ToolRefusalError({ status: "refused", blocker: "folder-parent-self", message: "parentId 不能等于目录自身(平台 FOLDER_PARENTID_EQUALS_ID)" });
287
+ const performed = await wireCall(session, {
288
+ path: ENDPOINTS.folderUpdate,
289
+ method: "POST",
290
+ body: { id: args.folderId, name: args.name ?? current.name, ...(args.name_i18n !== undefined ? { name_i18n: args.name_i18n } : {}), ...(args.parentId !== undefined ? { parentId: args.parentId } : {}) },
229
291
  });
230
- const performed = await wireCall(session, { path: ENDPOINTS.folderDelete, method: "POST", query: { folderId: args.folderId } });
231
- const after = await readTree(session, appCode);
232
- return {
233
- performed: { errcode: performed.errcode },
234
- readback: { folderId: args.folderId, deleted: !after.some((row) => String(row.id ?? "") === args.folderId), path: current.path },
235
- };
292
+ const after = await readTree(session, appCode);
293
+ const now = after.find((row) => String(row.id ?? "") === args.folderId);
294
+ return {
295
+ performed: { errcode: performed.errcode },
296
+ readback: {
297
+ folderId: args.folderId,
298
+ present: now !== undefined,
299
+ before: { name: current.name, parentId: current.parentId, path: current.path },
300
+ after: now ? { name: now.name, parentId: now.parentId, path: now.path } : null,
301
+ note: "只改了目录节点;目录内的模型不随目录移动(模型要动请用 model.move)",
302
+ },
303
+ };
304
+ },
236
305
  });
306
+ writeTool({
307
+ tools,
308
+ name: "folder.delete",
309
+ lane: LANE,
310
+ scope: T["folder.delete"].scope,
311
+ description: "删除空目录:`POST /api/app/folders/delete?folderId=`(AppFunctionController:246)。**非递归**——目录里还有子目录或模型时平台回 `DIRECTORY_NOT_EMPTY`,工具在写前先查子节点并拒绝(`folder-not-empty`),避免半删。要清空请先 `model.move` 移走模型、删子目录。",
312
+ inputSchema: {
313
+ applicationCode: z.string().optional().describe("应用 code;省略用配置默认应用(别名:appCode)"),
314
+ appCode: z.string().optional().describe("applicationCode 的别名(与 report.* 对齐);两者都给且不一致时报 app-code-conflict"),
315
+ folderId: z.string().min(1),
316
+ },
317
+ execute: async (session, args) => {
318
+ const appCode = await resolveApplicationCode(session, resolveFolderAppCode(args));
319
+ const before = await readTree(session, appCode);
320
+ const current = resolveFolder(before, args.folderId);
321
+ if (String(current.type ?? "") !== "Folder")
322
+ throw new ToolRefusalError({
323
+ status: "refused",
324
+ blocker: "folder-target-not-folder",
325
+ message: `${args.folderId} 不是目录节点(type=${String(current.type)});${folderDeleteTargetHint(String(current.type ?? ""), current)}`,
326
+ });
327
+ const children = before.filter((row) => String(row.parentId ?? "") === String(args.folderId));
328
+ if (children.length > 0)
329
+ throw new ToolRefusalError({
330
+ status: "refused",
331
+ blocker: "folder-not-empty",
332
+ folderId: args.folderId,
333
+ children: children.map((row) => ({ id: row.id, type: row.type, path: row.path })).slice(0, 40),
334
+ message: `目录「${current.path}」还有 ${children.length} 个子节点——平台非递归删除会回 DIRECTORY_NOT_EMPTY。先 model.move 移走模型 / 删子目录`,
335
+ });
336
+ const performed = await wireCall(session, { path: ENDPOINTS.folderDelete, method: "POST", query: { folderId: args.folderId } });
337
+ const after = await readTree(session, appCode);
338
+ return {
339
+ performed: { errcode: performed.errcode },
340
+ readback: { folderId: args.folderId, deleted: !after.some((row) => String(row.id ?? "") === args.folderId), path: current.path },
341
+ };
342
+ },
343
+ });
344
+ }
345
+ /**
346
+ * r27 F10: `folder.delete` must NOT send every non-Folder node to
347
+ * `model.delete` — a Report node then comes back `already-absent` and the
348
+ * app-tree residue stays. Dispatch by AppFunction type so the recovery
349
+ * points at the tool that actually owns that node.
350
+ */
351
+ export function folderDeleteTargetHint(type, current = {}) {
352
+ const code = String(current.code ?? "").trim();
353
+ const codeArg = code ? `{"code":"${code}"}` : '{"code":"<code>"}';
354
+ switch (type) {
355
+ case "Report":
356
+ return (`删报表用 omc call report.delete --args '${codeArg}'` +
357
+ "(若 BI 端点不可达,report.delete 会说明环境缺失)");
358
+ case "BizModel":
359
+ return code
360
+ ? `删模型用 omc call model.delete --args '{"schemaCode":"${code}"}'`
361
+ : "删模型用 model.delete";
362
+ case "CustomPage":
363
+ case "Page":
364
+ return `删自定义页用 omc call custompage.delete --args '${codeArg}'`;
365
+ default:
366
+ return "按节点 type 选择对应删除工具(folder.delete 只删空目录)";
367
+ }
237
368
  }
238
369
  export function registerFolderLane(tools) {
239
370
  folderTools(tools);
@@ -20,7 +20,7 @@ export const FORM_WIRE = Object.freeze({
20
20
  });
21
21
  const FORM_GET_DESCRIPTION = '读取默认表单(bizsheet/get)的发布状态与控件清单(紧凑默认)。**发布态以返回的 `published` / `existDraft` 布尔为准**(`published:true`=已发布;`published:false,existDraft:true`=只有草稿,**未发布**)——不要用 `publishedControlCodes` 是否非空来推断(草稿字段会让它非空,误判为已发布)。**每个模型交付前其默认表单必须 `published:true`**:用 `configure_form` 或 `form.draft(rebuild:true)`+`form.publish` 发布,并逐模型 `form.get` 核对。`full:true` 还返回 `options` 与解析后的 `customButtonJson`(表单工具栏按钮)。传 `controls:["code",…]` 只取指定控件的 options(如关联控件的 mappings);传 `full:true` 才返回 draft/published 的完整 view/attributes/html/actions JSON(actions 列为设计器保存/发布的表单级按钮动作)。体积大,仅在需要整体布局时用。';
22
22
  const FORM_DRAFT_DESCRIPTION = "Save the default-form DRAFT (bizsheet/update). Reads the current sheet, merges the caller's explicit draftViewJson/draftAttributesJson (or preserves the existing draft); when both are absent on a fresh default form, composes the designer-standard scaffold from the model's published fields. A redundant field (relation-mapping carry-through target) always stays bound; with no workflow it is hidden in the design (options.visible=false), with a workflow it stays visible for per-node propertyPermissions. Pass `draftHtmlJson` to thread custom online-script HTML through. Empty-layout guard: explicitly passing \"[]\"/\"{}\" view/attributes is refused (form-empty-layout) — the platform designer's Save is NOT lossless and clears drafts to exactly that shape; escape with allowEmptyLayout:true, and recover cleared drafts from the form version history (/api/api/app/bizsheet/history/list). **读回原样回写**:从 `form.get` 取回的 view/attributes 直接回传时加 `fromReadback:true`——工具会与已存草稿/发布面逐字比对,一致才放行(平台自身布局可能含子表列行/系统行默认宽度),被改动过的面照常按 validation 拒绝。**表单工具栏按钮**:`buttons`(数组)或 `customButtonJson`(原样字符串)写进 `biz_sheet.options.customButtonJson`;**要到 form.publish 才生成 h_biz_button(triggerType=FORM)**,且表单页是否显示还取决于流程节点 `buttonPermission.customBtns[].enabled`(设计器「表单操作」勾选)。`useLocation` 用 FORM/ROW/LIST,表单工具栏按钮用 FORM。 **表单控件属性(含关联填充 mappings)从字段行派生**:改关联映射的事实源在字段 options.mappings(field.update → field.publish),直接改这里的控件映射会被平台按字段重算覆盖(真机 2026-10-14 实证);字段发布后如需刷新已发布表单的控件映射,用本工具 rebuild:false 重存(保留现有布局与在线JS)再 form.publish。";
23
- const FORM_PUBLISH_DESCRIPTION = "Publish the default form (bizsheet/publish, proven shape: draft*Json promoted to published*Json verbatim) with published-face readback. Degrade guard: refuses to promote a draft carrying FEWER controls than the published face (form-publish-degrade) — the designer's Save clears drafts non-losslessly; recover via the form version history (/api/api/app/bizsheet/history/list) and rewrite the draft first, or pass allowDegrade:true to force the reduction. rebuild:true is the one-call refresh: discard the existing draft, re-scaffold from the published fields (non-structural control options preserved), then force-promote — use it after a field shape change. **表单工具栏按钮**:传 `buttons`(或 `customButtonJson`)→ 写进 `options.customButtonJson` 并在本次 publish 生成 `h_biz_button(triggerType=FORM, triggerCode=<formCode>)`(平台 `publishBizForm:1022`);`options.customButtonJson` 必须是**字符串化数组**(对象会 50000 syntax error);表单页是否显示再由流程节点 `buttonPermission.customBtns[].enabled` 过滤。";
23
+ const FORM_PUBLISH_DESCRIPTION = "Publish the default form (bizsheet/publish, proven shape: draft*Json promoted to published*Json verbatim) with published-face readback. **不影响在线 JS**:本工具只提拔布局草稿;改 JS 请用 onlinejs.compose(它自己会连同整张表单一起发布,不必再调本工具)。 Degrade guard: refuses to promote a draft carrying FEWER controls than the published face (form-publish-degrade) — the designer's Save clears drafts non-losslessly; recover via the form version history (/api/api/app/bizsheet/history/list) and rewrite the draft first, or pass allowDegrade:true to force the reduction. rebuild:true is the one-call refresh: discard the existing draft, re-scaffold from the published fields (non-structural control options preserved), then force-promote — use it after a field shape change. **表单工具栏按钮**:传 `buttons`(或 `customButtonJson`)→ 写进 `options.customButtonJson` 并在本次 publish 生成 `h_biz_button(triggerType=FORM, triggerCode=<formCode>)`(平台 `publishBizForm:1022`);`options.customButtonJson` 必须是**字符串化数组**(对象会 50000 syntax error);表单页是否显示再由流程节点 `buttonPermission.customBtns[].enabled` 过滤。";
24
24
  const FORM_RUN_BUTTON_DESCRIPTION = "执行表单工具栏按钮(平台同款面:`POST /api/api/runtime/form/executeCustomButton`,body `{bizButtonModel, bizObjectList:[<完整业务对象>], reacquireDataFromDb:true}`)。**与列表行按钮不同**:列表按钮发 `launcher/domain_service_runtime/<schema>/<rule>` + `ids[]`(用 `rule.run`),表单按钮带**整条记录**,不能互换。按钮来源 `GET /api/api/system/getCustomButtons`(只列 triggerType=FORM 的 h_biz_button)。前提:按钮已由 `form.publish` 的 `buttons`/`customButtonJson` 生成。返回是执行结果信封,规则副作用用 `data.load` 另读;被规则 UPDATE 的目标行须是 COMPLETED(引擎查不到 DRAFT)。`302045` = 数据被占用。**`10005` 执行失败时优先换等价面 `rule.run`(launcher/domain_service_runtime/<schema>/<rule> + ids[])**——同一绑定规则在 rule.run 面已验证可用(r28b 实证),不要在 executeCustomButton 上反复重试。";
25
25
  const CONFIGURE_FORM_DESCRIPTION = "High-level: bind fields into the default form and publish it (form.draft → form.publish), subject to the write permission gate. Delegates to the proven form.draft scaffold + form.publish paths so the real empty-form layout (and any caller-supplied draftHtmlJson) is persisted and promoted — never bypasses with a shortcut body. `sheetCode` is optional: omitted means the model's default sheet (sheetCode = schemaCode).";
26
26
  export const FORM_LANE_CONTRACT = Object.freeze({