@stalfh233/omc-cli 0.6.7 → 0.6.10

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
@@ -86,26 +86,89 @@ function normalizeRecord(record, dictionaryId, parentId) {
86
86
  sortKey: Number.isInteger(record.sortKey) ? record.sortKey : 0,
87
87
  };
88
88
  }
89
- function dictionaryView(value) {
89
+ /**
90
+ * r28a F1:默认读面只留身份/决策字段。`name_i18n` 在中文环境里与 `name`
91
+ * 冗余(二次序列化 JSON 串),每条多 ~60B;真翻译用 `full:true` / artifact。
92
+ */
93
+ function dictionaryView(value, options = {}) {
90
94
  const row = (value && typeof value === "object" ? value : {});
91
- return {
95
+ const view = {
92
96
  id: row.id,
93
97
  code: row.code,
94
98
  name: row.name,
95
- name_i18n: row.name_i18n,
96
99
  dictionaryType: row.dictionaryType,
97
100
  status: row.status,
98
101
  sortKey: row.sortKey,
99
102
  classificationId: row.classificationId,
100
103
  classificationName: row.classificationName,
101
104
  };
105
+ if (options.full)
106
+ view.name_i18n = row.name_i18n;
107
+ else if (!redundantNameI18n(row.name_i18n, row.name))
108
+ view.name_i18n = row.name_i18n;
109
+ return view;
102
110
  }
103
- function recordsPage(value) {
111
+ /**
112
+ * r28a F3/F6:记录瘦投影——绑定字段只要 id/code/name;审计列与冗余
113
+ * name_i18n 默认丢掉。`full:true` 保留原生行。
114
+ */
115
+ function recordView(value, options = {}) {
116
+ const row = (value && typeof value === "object" ? value : {});
117
+ if (options.full)
118
+ return { ...row };
119
+ const view = {
120
+ id: row.id,
121
+ code: row.code,
122
+ name: row.name,
123
+ status: row.status,
124
+ sortKey: row.sortKey,
125
+ parentId: row.parentId ?? null,
126
+ };
127
+ if (!redundantNameI18n(row.name_i18n, row.name) && row.name_i18n !== undefined)
128
+ view.name_i18n = row.name_i18n;
129
+ if (Array.isArray(row.children) && row.children.length > 0) {
130
+ view.children = row.children.map((child) => recordView(child, options));
131
+ }
132
+ return view;
133
+ }
134
+ function redundantNameI18n(nameI18n, name) {
135
+ if (typeof name !== "string")
136
+ return false;
137
+ let parsed = nameI18n;
138
+ if (typeof nameI18n === "string") {
139
+ try {
140
+ parsed = JSON.parse(nameI18n);
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
147
+ return false;
148
+ const values = Object.values(parsed);
149
+ return values.length > 0 && values.every((value) => value === name);
150
+ }
151
+ /** Stable order for readbacks: sortKey asc, then code. Platform order is not submission order (r28a F3). */
152
+ function sortRecordsBySortKey(rows) {
153
+ return [...rows]
154
+ .map((row) => (row && typeof row === "object" ? row : {}))
155
+ .sort((left, right) => {
156
+ const leftKey = Number(left.sortKey ?? 0);
157
+ const rightKey = Number(right.sortKey ?? 0);
158
+ if (leftKey !== rightKey)
159
+ return leftKey - rightKey;
160
+ return String(left.code ?? "").localeCompare(String(right.code ?? ""));
161
+ });
162
+ }
163
+ const RECORDS_ORDER_NOTE = "records 顺序不保证与提交一致;顺序请用 sortKey(回读已按 sortKey 升序,同值再按 code)";
164
+ function recordsPage(value, options = {}) {
104
165
  const page = (value && typeof value === "object" ? value : {});
166
+ const raw = Array.isArray(page.content) ? page.content : [];
167
+ const ordered = options.sort === false ? raw : sortRecordsBySortKey(raw);
105
168
  return {
106
- totalElements: page.totalElements ?? 0,
169
+ totalElements: page.totalElements ?? ordered.length,
107
170
  totalPages: page.totalPages ?? 0,
108
- content: Array.isArray(page.content) ? page.content : [],
171
+ content: ordered.map((row) => recordView(row, options)),
109
172
  };
110
173
  }
111
174
  function requireExactlyOne(id, code) {
@@ -120,11 +183,20 @@ export function registerDictionaryLane(tools) {
120
183
  lane: LANE,
121
184
  scope: TOOLS["dictionary.list"].scope,
122
185
  description: TOOLS["dictionary.list"].description,
123
- inputSchema: { environment: environmentArg, capability: capabilityArg, classificationId: z.string().optional(), searchWord: z.string().optional(), page: z.number().int().min(1).default(1), size: z.number().int().min(1).max(200).default(20), sortColumn: z.string().optional(), sortDirection: z.enum(["asc", "desc"]).optional() },
186
+ inputSchema: { environment: environmentArg, capability: capabilityArg, classificationId: z.string().optional(), searchWord: z.string().optional(), page: z.number().int().min(1).default(1), size: z.number().int().min(1).max(200).default(20), sortColumn: z.string().optional(), sortDirection: z.enum(["asc", "desc"]).optional(), full: z.boolean().default(false) },
124
187
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
125
188
  const data = await wireCall(session, { path: ENDPOINTS.dictionarySearch, query: { classificationId: args.classificationId ?? "", searchWord: args.searchWord ?? "", page: String(args.page), size: String(args.size), sortColumn: args.sortColumn ?? "", sortDirection: args.sortDirection ?? "desc" } });
126
- return { dictionaries: recordsPage(data.data) };
127
- }),
189
+ // r28a F1/F2:分页 envelope 保留;顶层再给 total,避免 --jq totalElements 路径迷失。
190
+ // list 的 content 是字典头(不是 records),用 dictionaryView 瘦投影。
191
+ const page = (data.data && typeof data.data === "object" ? data.data : {});
192
+ const raw = Array.isArray(page.content) ? page.content : [];
193
+ const dictionaries = {
194
+ totalElements: page.totalElements ?? raw.length,
195
+ totalPages: page.totalPages ?? 0,
196
+ content: raw.map((row) => dictionaryView(row, { full: args.full })),
197
+ };
198
+ return { dictionaries, total: Number(dictionaries.totalElements ?? 0) };
199
+ }, { full: args.full }),
128
200
  }));
129
201
  tools.push(defineTool({
130
202
  name: "dictionary.type",
@@ -133,11 +205,12 @@ export function registerDictionaryLane(tools) {
133
205
  lane: LANE,
134
206
  scope: TOOLS["dictionary.type"].scope,
135
207
  description: TOOLS["dictionary.type"].description,
136
- inputSchema: { environment: environmentArg, capability: capabilityArg, type: dictionaryType },
208
+ inputSchema: { environment: environmentArg, capability: capabilityArg, type: dictionaryType, full: z.boolean().default(false) },
137
209
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
138
210
  const data = await wireCall(session, { path: ENDPOINTS.dictionaryGetByType, query: { type: String(args.type) } });
139
- return { dictionaryType: args.type, dictionaries: Array.isArray(data.data) ? data.data.map(dictionaryView) : [] };
140
- }),
211
+ const dictionaries = Array.isArray(data.data) ? data.data.map((row) => dictionaryView(row, { full: args.full })) : [];
212
+ return { dictionaryType: args.type, dictionaries, total: dictionaries.length };
213
+ }, { full: args.full }),
141
214
  }));
142
215
  tools.push(defineTool({
143
216
  name: "dictionary.names",
@@ -146,11 +219,19 @@ export function registerDictionaryLane(tools) {
146
219
  lane: LANE,
147
220
  scope: TOOLS["dictionary.names"].scope,
148
221
  description: TOOLS["dictionary.names"].description,
149
- inputSchema: { environment: environmentArg, capability: capabilityArg },
222
+ inputSchema: { environment: environmentArg, capability: capabilityArg, full: z.boolean().default(false) },
150
223
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
224
+ // r28a F4/F5:平台 getDictionaryIdAndNames 的覆盖可能小于 searchDictionary
225
+ // (真机 7 vs 18)。CLI 如实回传平台数组并显式给 total + coverageNote;
226
+ // 全量/防冲突请用 dictionary.list(可分页)。
151
227
  const data = await wireCall(session, { path: ENDPOINTS.dictionaryIdAndNames, query: {} });
152
- return { dictionaries: Array.isArray(data.data) ? data.data.map(dictionaryView) : [] };
153
- }),
228
+ const dictionaries = Array.isArray(data.data) ? data.data.map((row) => dictionaryView(row, { full: args.full })) : [];
229
+ return {
230
+ dictionaries,
231
+ total: dictionaries.length,
232
+ coverageNote: "dictionary.names 透传平台 getDictionaryIdAndNames;其覆盖可能小于 dictionary.list(searchDictionary)。全量清单/防 code 冲突请用 dictionary.list(--jq 'dictionaries.totalElements,dictionaries.content[].code' 或看顶层 total)。",
233
+ };
234
+ }, { full: args.full }),
154
235
  }));
155
236
  tools.push(defineTool({
156
237
  name: "dictionary.get",
@@ -159,12 +240,12 @@ export function registerDictionaryLane(tools) {
159
240
  lane: LANE,
160
241
  scope: TOOLS["dictionary.get"].scope,
161
242
  description: TOOLS["dictionary.get"].description,
162
- inputSchema: { environment: environmentArg, capability: capabilityArg, id: z.string().optional(), code: z.string().optional() },
243
+ inputSchema: { environment: environmentArg, capability: capabilityArg, id: z.string().optional(), code: z.string().optional(), full: z.boolean().default(false) },
163
244
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
164
245
  requireExactlyOne(args.id, args.code);
165
246
  const data = await wireCall(session, { path: args.id ? ENDPOINTS.dictionaryGet : ENDPOINTS.dictionaryGetByCode, query: args.id ? { id: args.id } : { code: args.code } });
166
- return { dictionary: dictionaryView(data.data) };
167
- }),
247
+ return { dictionary: dictionaryView(data.data, { full: args.full }) };
248
+ }, { full: args.full }),
168
249
  }));
169
250
  tools.push(defineTool({
170
251
  name: "dictionary.records",
@@ -173,12 +254,14 @@ export function registerDictionaryLane(tools) {
173
254
  lane: LANE,
174
255
  scope: TOOLS["dictionary.records"].scope,
175
256
  description: TOOLS["dictionary.records"].description,
176
- inputSchema: { environment: environmentArg, capability: capabilityArg, id: z.string().optional(), code: z.string().optional(), parentId: z.string().optional(), searchWord: z.string().optional(), enabledOnly: z.boolean().default(false), page: z.number().int().min(1).default(1), size: z.number().int().min(1).max(200).default(200) },
257
+ inputSchema: { environment: environmentArg, capability: capabilityArg, id: z.string().optional(), code: z.string().optional(), parentId: z.string().optional(), searchWord: z.string().optional(), enabledOnly: z.boolean().default(false), page: z.number().int().min(1).default(1), size: z.number().int().min(1).max(200).default(200), full: z.boolean().default(false) },
177
258
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
178
259
  requireExactlyOne(args.id, args.code);
260
+ const projectRows = (rows) => sortRecordsBySortKey(rows).map((row) => recordView(row, { full: args.full }));
179
261
  if (args.parentId) {
180
262
  const data = await wireCall(session, { path: ENDPOINTS.dictionaryParentRecords, query: { parentId: args.parentId } });
181
- return { parentId: args.parentId, records: Array.isArray(data.data) ? data.data : [] };
263
+ const records = Array.isArray(data.data) ? projectRows(data.data) : [];
264
+ return { parentId: args.parentId, records, total: records.length, orderNote: RECORDS_ORDER_NOTE };
182
265
  }
183
266
  if (args.enabledOnly) {
184
267
  const dictionary = args.id ? { id: args.id } : (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code: args.code } })).data;
@@ -186,57 +269,99 @@ export function registerDictionaryLane(tools) {
186
269
  if (!id)
187
270
  throw new WireError("business", "dictionary code not found", 301001);
188
271
  const data = await wireCall(session, { path: ENDPOINTS.dictionaryEnabledRecords, query: { dicId: id } });
189
- return { dictionaryId: id, enabledOnly: true, records: Array.isArray(data.data) ? data.data : [] };
272
+ const records = Array.isArray(data.data) ? projectRows(data.data) : [];
273
+ return { dictionaryId: id, enabledOnly: true, records, total: records.length, orderNote: RECORDS_ORDER_NOTE };
190
274
  }
191
275
  const path = args.code ? ENDPOINTS.dictionaryRecordsByCode : ENDPOINTS.dictionaryRecords;
192
276
  const query = args.code
193
277
  ? { code: args.code, searchWord: args.searchWord ?? "", page: String(args.page), size: String(args.size) }
194
278
  : { id: args.id, searchWord: args.searchWord ?? "", page: String(args.page), size: String(args.size) };
195
279
  const data = await wireCall(session, { path, query });
196
- return { records: recordsPage(data.data) };
197
- }),
280
+ const page = recordsPage(data.data, { full: args.full });
281
+ return { records: page, total: Number(page.totalElements ?? 0), orderNote: RECORDS_ORDER_NOTE };
282
+ }, { full: args.full }),
198
283
  }));
199
- writeTool(tools, "dictionary.save", LANE, TOOLS["dictionary.save"].scope, TOOLS["dictionary.save"].description, {
200
- id: z.string().optional(), code: z.string().min(1), name: z.string().min(1), name_i18n: nameI18n, dictionaryType, classificationId: z.string().optional(), sortKey: z.number().int().optional(), status: z.boolean().optional(), records: recordsArg,
201
- }, async (session, args) => {
202
- const existing = (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code: args.code } })).data;
203
- if (!args.id && existing && typeof existing === "object" && Object.keys(existing).length > 0)
204
- throw new ToolRefusalError({ status: "refused", blocker: "dictionary-create-duplicate", code: args.code, message: `字典 ${args.code} 已存在;更新请传 id` });
205
- const body = {
206
- ...(args.id ? { id: args.id } : {}), code: args.code, name: args.name, name_i18n: JSON.stringify(args.name_i18n ?? { "zh-CN": args.name, en: args.name }), dictionaryType: args.dictionaryType,
207
- classificationId: args.classificationId ?? "", sortKey: args.sortKey ?? 0, status: args.status ?? true,
208
- records: (args.records ?? []).map((record) => normalizeRecord(record)),
209
- };
210
- const performed = await wireCall(session, { path: ENDPOINTS.dictionarySave, method: "POST", body });
211
- const after = (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code: args.code } })).data;
212
- // The create/update response only carries the header; the records the caller
213
- // just sent are invisible unless they make another call (dogfood R3). Read
214
- // them back here so "the header saved" cannot be mistaken for "the records
215
- // were verified".
216
- let recordsReadback = { totalElements: 0, totalPages: 0, content: [] };
217
- try {
218
- const stored = (await wireCall(session, { path: ENDPOINTS.dictionaryEnabledRecordsByCode, query: { code: args.code } })).data;
219
- const all = (await wireCall(session, { path: ENDPOINTS.dictionaryRecordsByCode, query: { code: args.code, searchWord: "", page: "1", size: "200" } })).data;
220
- recordsReadback = { ...recordsPage(all), enabledCount: Array.isArray(stored) ? stored.length : 0 };
221
- }
222
- catch {
223
- // best-effort: the save itself is already authoritative; a record-read
224
- // failure must not turn a landed write into a failure.
225
- }
226
- return { performed: { errcode: performed.errcode }, readback: { present: Boolean(after && Object.keys(after).length > 0), dictionary: dictionaryView(after), records: recordsReadback } };
227
- }, undefined, undefined, dictionaryRollbackSpec("dictionary.save"));
228
- writeTool(tools, "dictionary.status", LANE, TOOLS["dictionary.status"].scope, TOOLS["dictionary.status"].description, {
229
- target: z.enum(["dictionary", "record"]), id: z.string().min(1), status: z.boolean(),
230
- }, async (session, args) => {
231
- const path = args.target === "dictionary" ? ENDPOINTS.dictionaryUpdateStatus : ENDPOINTS.dictionaryRecordStatus;
232
- const performed = await wireCall(session, { path, method: "POST", body: { id: args.id, status: args.status } });
233
- return { performed: { errcode: performed.errcode }, readback: { id: args.id, status: args.status } };
284
+ writeTool({
285
+ tools,
286
+ name: "dictionary.save",
287
+ lane: LANE,
288
+ scope: TOOLS["dictionary.save"].scope,
289
+ description: TOOLS["dictionary.save"].description,
290
+ inputSchema: {
291
+ id: z.string().optional(), code: z.string().min(1), name: z.string().min(1), name_i18n: nameI18n, dictionaryType, classificationId: z.string().optional(), sortKey: z.number().int().optional(), status: z.boolean().optional(), records: recordsArg,
292
+ },
293
+ execute: async (session, args) => {
294
+ const existing = (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code: args.code } })).data;
295
+ if (!args.id && existing && typeof existing === "object" && Object.keys(existing).length > 0)
296
+ throw new ToolRefusalError({ status: "refused", blocker: "dictionary-create-duplicate", code: args.code, message: `字典 ${args.code} 已存在;更新请传 id` });
297
+ const submittedRecords = args.records;
298
+ const body = {
299
+ ...(args.id ? { id: args.id } : {}), code: args.code, name: args.name, name_i18n: JSON.stringify(args.name_i18n ?? { "zh-CN": args.name, en: args.name }), dictionaryType: args.dictionaryType,
300
+ classificationId: args.classificationId ?? "", sortKey: args.sortKey ?? 0, status: args.status ?? true,
301
+ records: (submittedRecords ?? []).map((record) => normalizeRecord(record)),
302
+ };
303
+ const performed = await wireCall(session, { path: ENDPOINTS.dictionarySave, method: "POST", body });
304
+ const after = (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code: args.code } })).data;
305
+ // The create/update response only carries the header; the records the caller
306
+ // just sent are invisible unless they make another call (dogfood R3). Read
307
+ // them back here so "the header saved" cannot be mistaken for "the records
308
+ // were verified".
309
+ let recordsReadback = { totalElements: 0, totalPages: 0, content: [] };
310
+ try {
311
+ const stored = (await wireCall(session, { path: ENDPOINTS.dictionaryEnabledRecordsByCode, query: { code: args.code } })).data;
312
+ const all = (await wireCall(session, { path: ENDPOINTS.dictionaryRecordsByCode, query: { code: args.code, searchWord: "", page: "1", size: "200" } })).data;
313
+ // r28a F3:瘦投影 + 按 sortKey 排序;顺序不可依赖提交顺序。
314
+ recordsReadback = { ...recordsPage(all), enabledCount: Array.isArray(stored) ? stored.length : 0 };
315
+ }
316
+ catch {
317
+ // best-effort: the save itself is already authoritative; a record-read
318
+ // failure must not turn a landed write into a failure.
319
+ }
320
+ const readback = {
321
+ present: Boolean(after && Object.keys(after).length > 0),
322
+ dictionary: dictionaryView(after),
323
+ records: recordsReadback,
324
+ orderNote: RECORDS_ORDER_NOTE,
325
+ };
326
+ // r28a F13:records:[] 是增量 upsert 空操作,不会清空既有记录;顶层 ok 会
327
+ // 伪装成「清空成功」。只要调用方显式传了 [],就标 intentUnmet(不依赖
328
+ // 读回条数——部分 fake/版本族可能把 [] 当全量替换,平台真机不会)。
329
+ if (Array.isArray(submittedRecords) && submittedRecords.length === 0) {
330
+ readback.intentUnmet = true;
331
+ readback.intentUnmetReason = "records:[] 不会删除既有记录(平台无字典/记录删除端点,save 是增量 upsert);请用 dictionary.status 逐条停用。";
332
+ }
333
+ return { performed: { errcode: performed.errcode }, readback };
334
+ },
335
+ rollback: dictionaryRollbackSpec("dictionary.save"),
234
336
  });
235
- writeTool(tools, "dictionary.usage", LANE, TOOLS["dictionary.usage"].scope, TOOLS["dictionary.usage"].description, {
236
- ids: z.array(z.string().min(1)).min(1).max(1000),
237
- }, async (session, args) => {
238
- const performed = await wireCall(session, { path: ENDPOINTS.dictionaryRecordUsed, method: "POST", body: { id: args.ids.join(";") } });
239
- return { performed: { errcode: performed.errcode, ids: args.ids }, readback: { ids: args.ids, initialUsed: true } };
337
+ writeTool({
338
+ tools,
339
+ name: "dictionary.status",
340
+ lane: LANE,
341
+ scope: TOOLS["dictionary.status"].scope,
342
+ description: TOOLS["dictionary.status"].description,
343
+ inputSchema: {
344
+ target: z.enum(["dictionary", "record"]), id: z.string().min(1), status: z.boolean(),
345
+ },
346
+ execute: async (session, args) => {
347
+ const path = args.target === "dictionary" ? ENDPOINTS.dictionaryUpdateStatus : ENDPOINTS.dictionaryRecordStatus;
348
+ const performed = await wireCall(session, { path, method: "POST", body: { id: args.id, status: args.status } });
349
+ return { performed: { errcode: performed.errcode }, readback: { id: args.id, status: args.status } };
350
+ },
351
+ });
352
+ writeTool({
353
+ tools,
354
+ name: "dictionary.usage",
355
+ lane: LANE,
356
+ scope: TOOLS["dictionary.usage"].scope,
357
+ description: TOOLS["dictionary.usage"].description,
358
+ inputSchema: {
359
+ ids: z.array(z.string().min(1)).min(1).max(1000),
360
+ },
361
+ execute: async (session, args) => {
362
+ const performed = await wireCall(session, { path: ENDPOINTS.dictionaryRecordUsed, method: "POST", body: { id: args.ids.join(";") } });
363
+ return { performed: { errcode: performed.errcode, ids: args.ids }, readback: { ids: args.ids, initialUsed: true } };
364
+ },
240
365
  });
241
366
  tools.push(defineTool({
242
367
  name: "dictionary.parent",
@@ -264,53 +389,71 @@ export function registerDictionaryLane(tools) {
264
389
  return { detail: args.detail, classes: Array.isArray(data.data) ? data.data : [] };
265
390
  }),
266
391
  }));
267
- writeTool(tools, "dictionary.class.save", LANE, TOOLS["dictionary.class.save"].scope, TOOLS["dictionary.class.save"].description, {
268
- id: z.string().optional(), name: z.string().min(1), dictionaryIds: z.array(z.string()).optional(), sortKey: z.number().int().optional(),
269
- }, async (session, args) => {
270
- const performed = await wireCall(session, { path: ENDPOINTS.dictionaryClassSave, method: "POST", body: { ...(args.id ? { id: args.id } : {}), name: args.name, dictionaryIds: args.dictionaryIds ?? [], sortKey: args.sortKey ?? 0 } });
271
- // Read back the created/updated class so the caller (and the audit trail)
272
- // gets its id, not just the name it sent.
273
- const listed = (await wireCall(session, { path: ENDPOINTS.dictionaryClasses, query: {} })).data;
274
- const classes = Array.isArray(listed) ? listed : [];
275
- const saved = classes.find((entry) => entry.name === args.name && (args.id === undefined || entry.id === args.id)) ?? classes.find((entry) => entry.name === args.name);
276
- return { performed: { errcode: performed.errcode }, readback: { present: saved !== undefined, id: saved?.id, name: args.name, dictionaryIds: args.dictionaryIds ?? [] } };
277
- }, undefined, undefined, dictionaryRollbackSpec("dictionary.class.save"));
278
- writeTool(tools, "dictionary.class.delete", LANE, TOOLS["dictionary.class.delete"].scope, TOOLS["dictionary.class.delete"].description, {
279
- ids: z.array(z.string().min(1)).min(1),
280
- }, async (session, args) => {
281
- // The platform's deleteDictionaryClass(Batch) answers errcode 0 for ANY id —
282
- // a dictionary id, a typo, anything — while deleting nothing. Writing
283
- // `absent: true` unconditionally turned that into a silent false success
284
- // (2026-10-30 probe: passing a dictionary id reported success and left the
285
- // object in place). Verify the inputs are class ids BEFORE the write and
286
- // derive absence from a post-delete read.
287
- const classIds = async () => {
392
+ writeTool({
393
+ tools,
394
+ name: "dictionary.class.save",
395
+ lane: LANE,
396
+ scope: TOOLS["dictionary.class.save"].scope,
397
+ description: TOOLS["dictionary.class.save"].description,
398
+ inputSchema: {
399
+ id: z.string().optional(), name: z.string().min(1), dictionaryIds: z.array(z.string()).optional(), sortKey: z.number().int().optional(),
400
+ },
401
+ execute: async (session, args) => {
402
+ const performed = await wireCall(session, { path: ENDPOINTS.dictionaryClassSave, method: "POST", body: { ...(args.id ? { id: args.id } : {}), name: args.name, dictionaryIds: args.dictionaryIds ?? [], sortKey: args.sortKey ?? 0 } });
403
+ // Read back the created/updated class so the caller (and the audit trail)
404
+ // gets its id, not just the name it sent.
288
405
  const listed = (await wireCall(session, { path: ENDPOINTS.dictionaryClasses, query: {} })).data;
289
- return new Set((Array.isArray(listed) ? listed : [])
290
- .map((entry) => (typeof entry.id === "string" ? entry.id : ""))
291
- .filter((id) => id !== ""));
292
- };
293
- const before = await classIds();
294
- const unmatched = args.ids.filter((id) => !before.has(id));
295
- if (unmatched.length > 0) {
296
- throw new ToolRefusalError({
297
- status: "refused",
298
- blocker: "dictionary-class-absent",
299
- ids: args.ids,
300
- unmatched,
301
- candidates: [...before].slice(0, 20),
302
- message: `${unmatched.join("、")} 不是字典分类 id(删前分类列表里没有它)。平台 deleteDictionaryClass 对任意 id 都回 errcode 0 却不删任何行——传字典 id 会假成功。现有分类候选:${[...before].slice(0, 20).join("、") || "(当前没有分类)"}。另:字典本体与字典项平台无删除端点,只能 dictionary.status 停用。`,
303
- });
304
- }
305
- const path = args.ids.length === 1 ? ENDPOINTS.dictionaryClassDelete : ENDPOINTS.dictionaryClassDeleteBatch;
306
- const body = args.ids.length === 1 ? { id: args.ids[0] } : { ids: args.ids };
307
- const performed = await wireCall(session, { path, method: "POST", body });
308
- const after = await classIds();
309
- const stillPresent = args.ids.filter((id) => after.has(id));
310
- return {
311
- performed: { errcode: performed.errcode, ids: args.ids },
312
- readback: { absent: stillPresent.length === 0, ids: args.ids, ...(stillPresent.length > 0 ? { stillPresent } : {}) },
313
- };
314
- }, undefined, undefined, dictionaryRollbackSpec("dictionary.class.delete"));
406
+ const classes = Array.isArray(listed) ? listed : [];
407
+ const saved = classes.find((entry) => entry.name === args.name && (args.id === undefined || entry.id === args.id)) ?? classes.find((entry) => entry.name === args.name);
408
+ return { performed: { errcode: performed.errcode }, readback: { present: saved !== undefined, id: saved?.id, name: args.name, dictionaryIds: args.dictionaryIds ?? [] } };
409
+ },
410
+ rollback: dictionaryRollbackSpec("dictionary.class.save"),
411
+ });
412
+ writeTool({
413
+ tools,
414
+ name: "dictionary.class.delete",
415
+ lane: LANE,
416
+ scope: TOOLS["dictionary.class.delete"].scope,
417
+ description: TOOLS["dictionary.class.delete"].description,
418
+ inputSchema: {
419
+ ids: z.array(z.string().min(1)).min(1),
420
+ },
421
+ execute: async (session, args) => {
422
+ // The platform's deleteDictionaryClass(Batch) answers errcode 0 for ANY id —
423
+ // a dictionary id, a typo, anything — while deleting nothing. Writing
424
+ // `absent: true` unconditionally turned that into a silent false success
425
+ // (2026-10-30 probe: passing a dictionary id reported success and left the
426
+ // object in place). Verify the inputs are class ids BEFORE the write and
427
+ // derive absence from a post-delete read.
428
+ const classIds = async () => {
429
+ const listed = (await wireCall(session, { path: ENDPOINTS.dictionaryClasses, query: {} })).data;
430
+ return new Set((Array.isArray(listed) ? listed : [])
431
+ .map((entry) => (typeof entry.id === "string" ? entry.id : ""))
432
+ .filter((id) => id !== ""));
433
+ };
434
+ const before = await classIds();
435
+ const unmatched = args.ids.filter((id) => !before.has(id));
436
+ if (unmatched.length > 0) {
437
+ throw new ToolRefusalError({
438
+ status: "refused",
439
+ blocker: "dictionary-class-absent",
440
+ ids: args.ids,
441
+ unmatched,
442
+ candidates: [...before].slice(0, 20),
443
+ message: `${unmatched.join("、")} 不是字典分类 id(删前分类列表里没有它)。平台 deleteDictionaryClass 对任意 id 都回 errcode 0 却不删任何行——传字典 id 会假成功。现有分类候选:${[...before].slice(0, 20).join("、") || "(当前没有分类)"}。另:字典本体与字典项平台无删除端点,只能 dictionary.status 停用。`,
444
+ });
445
+ }
446
+ const path = args.ids.length === 1 ? ENDPOINTS.dictionaryClassDelete : ENDPOINTS.dictionaryClassDeleteBatch;
447
+ const body = args.ids.length === 1 ? { id: args.ids[0] } : { ids: args.ids };
448
+ const performed = await wireCall(session, { path, method: "POST", body });
449
+ const after = await classIds();
450
+ const stillPresent = args.ids.filter((id) => after.has(id));
451
+ return {
452
+ performed: { errcode: performed.errcode, ids: args.ids },
453
+ readback: { absent: stillPresent.length === 0, ids: args.ids, ...(stillPresent.length > 0 ? { stillPresent } : {}) },
454
+ };
455
+ },
456
+ rollback: dictionaryRollbackSpec("dictionary.class.delete"),
457
+ });
315
458
  }
316
459
  //# sourceMappingURL=dictionary.js.map
@@ -31,8 +31,19 @@ export function registerEnvironmentLane(tools) {
31
31
  // 2–3 次 wire(r31–r37 每会话重新盘点的纯浪费)。refresh:true 强制重查。
32
32
  if (args.refresh !== true && args.applicationCode === undefined && args.modelCode === undefined && args.full !== true) {
33
33
  const cached = readProjectCard(ctx.artifactsDir, String(args.environment ?? session.environmentName ?? ""));
34
- if (cached !== undefined)
35
- return { ...cached, status: "ok", fromCache: true };
34
+ if (cached !== undefined) {
35
+ // r28b F16(真机 2026-11-05,本轮复现):实时读回给 `modelsCount`,
36
+ // 缓存命中给 `modelCount`——同一个键换条路径就换名字,脚本 `r["modelsCount"]`
37
+ // 直接 KeyError,基线对比也说不清是模型变了还是口径变了。
38
+ // 缓存是加速,不是第二套契约:键名与实时读对齐,旧键保留不破既有调用方。
39
+ const { modelCount, ...rest } = cached;
40
+ return {
41
+ ...rest,
42
+ ...(typeof modelCount === "number" ? { modelsCount: modelCount, modelCount } : {}),
43
+ status: "ok",
44
+ fromCache: true,
45
+ };
46
+ }
36
47
  }
37
48
  // List every application the tenant exposes — the configured
38
49
  // applicationCode is a default, never the only application. Resolve
@@ -146,6 +157,11 @@ export function registerEnvironmentLane(tools) {
146
157
  writeProjectCard(ctx.artifactsDir, {
147
158
  environment: envName,
148
159
  applicationCode: selected,
160
+ // r28k F2:缓存命中必须与实时读**同形**,否则同工具同参数第二次
161
+ // 调用的 result 键集就变了(applications 消失 → --jq 直接 pathMiss)。
162
+ applications: apps,
163
+ groups,
164
+ applicationGroups: { status: groupStatus.status },
149
165
  ...(applicationResolution !== undefined ? { applicationResolution: { code: String(selected ?? applicationResolution.requested ?? ""), source: String(applicationResolution.source ?? "") } } : {}),
150
166
  models: discovered.models.slice(0, 300).map((row) => ({ code: String(row.schemaCode), name: String(row.name ?? "") })),
151
167
  modelCount: discovered.models.length,
@@ -153,18 +169,46 @@ export function registerEnvironmentLane(tools) {
153
169
  });
154
170
  }
155
171
  }
156
- return discovered;
172
+ return {
173
+ ...discovered,
174
+ // r28k F3:裸 hex 不解自用。指纹是 (env, origin, application, enterprise)
175
+ // 的规范 sha256——绑定写计划 / intent plan 漂移门 / draft 写前复核 /
176
+ // 读回缓存键;它不是审批令牌。
177
+ fingerprintHint: FINGERPRINT_HINT,
178
+ };
157
179
  }, {
158
180
  full: args.full === true,
159
181
  target: `discover_environment:${args.applicationCode ?? ""}:${args.modelCode ?? ""}`,
160
182
  compact: (result) => {
183
+ // r28k F1(真机 2026-11-05):旧 compact 把 applications/foldersAndModels/models
184
+ // 全删掉,再由 read.ts 的占位补回 `[]`——与「确实没有应用」同形,只能靠
185
+ // truncated:true 旁证。应用通常个位数,内联完全值得;大表才标 *Omitted。
161
186
  const compact = { ...result };
162
- for (const key of ["applications", "groups", "foldersAndModels", "models"]) {
187
+ const apps = compact.applications;
188
+ if (Array.isArray(apps)) {
189
+ compact.applicationsCount = apps.length;
190
+ // 保留完整 applications(个位数);不删 key,避免占位 [] 覆盖。
191
+ }
192
+ const groups = compact.groups;
193
+ if (Array.isArray(groups)) {
194
+ compact.groupsCount = groups.length;
195
+ // groups 通常更小,一并内联。
196
+ }
197
+ for (const key of ["foldersAndModels", "models"]) {
163
198
  const value = compact[key];
164
- if (Array.isArray(value)) {
165
- compact[`${key}Count`] = value.length;
166
- delete compact[key];
167
- }
199
+ if (!Array.isArray(value))
200
+ continue;
201
+ compact[`${key}Count`] = value.length;
202
+ // 保留 key 但置 null + *Omitted,形状上区别于真实空数组 [];
203
+ // key 仍在 payload 里,read.ts 不会再补空占位。
204
+ compact[key] = null;
205
+ compact[`${key}Omitted`] = true;
206
+ }
207
+ if (compact.foldersAndModelsOmitted === true || compact.modelsOmitted === true) {
208
+ compact.tablesHint = "foldersAndModels/models 已外置(*Omitted:true,不是空表);用 detailArtifactId 的 `omc artifact get <id> --jq <key>` 或同次 call 加 --jq/--extract 取回。applications/groups 已内联。";
209
+ }
210
+ if (typeof compact.environmentFingerprint === "string" && compact.fingerprintHint === undefined) {
211
+ compact.fingerprintHint = FINGERPRINT_HINT;
168
212
  }
169
213
  return compact;
170
214
  },
@@ -176,12 +220,16 @@ export function registerEnvironmentLane(tools) {
176
220
  kind: "read",
177
221
  lane: ENVIRONMENT_LANE,
178
222
  scope: ENVIRONMENT_TOOLS["environment.fingerprint"].scope,
179
- description: ENVIRONMENT_TOOLS["environment.fingerprint"].description,
223
+ // r28k F3:用途放首句,避免 summary 在第一个英文句号处截断。
224
+ description: `${FINGERPRINT_HINT} ${ENVIRONMENT_TOOLS["environment.fingerprint"].description}`,
180
225
  inputSchema: { environment: environmentArg, capability: capabilityArg },
181
226
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => ({
182
227
  environment: session.environmentName,
183
228
  fingerprint: session.environmentFingerprint(),
229
+ fingerprintHint: FINGERPRINT_HINT,
184
230
  })),
185
231
  }));
186
232
  }
233
+ /** r28k F3 — 指纹用途一句话(回执 + describe 共用,避免两处漂移)。 */
234
+ const FINGERPRINT_HINT = "用途:写前/写后漂移检测(intent plan、draft 复核)、写计划与审计 artifact 绑定、读回缓存键,随 env/origin/application/enterprise 变,不是审批令牌。";
187
235
  //# sourceMappingURL=environment.js.map