@stalfh233/omc-cli 0.6.12 → 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 (57) 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 +8 -2
  8. package/dist/cli.js +279 -67
  9. package/dist/describe-cache.js +8 -4
  10. package/dist/environment-policy.js +20 -0
  11. package/dist/field-families.js +62 -4
  12. package/dist/form-support.js +293 -6
  13. package/dist/goal-routes.js +137 -22
  14. package/dist/identity.js +63 -11
  15. package/dist/lanes/app-contract.js +4 -4
  16. package/dist/lanes/app.js +19 -1
  17. package/dist/lanes/dictionary-contract.js +16 -5
  18. package/dist/lanes/dictionary-coverage.js +1 -0
  19. package/dist/lanes/dictionary.js +200 -8
  20. package/dist/lanes/environment-contract.js +18 -6
  21. package/dist/lanes/environment.js +129 -16
  22. package/dist/lanes/field-contract.js +2 -1
  23. package/dist/lanes/field.js +707 -59
  24. package/dist/lanes/form.js +52 -6
  25. package/dist/lanes/list-view-contract.js +2 -2
  26. package/dist/lanes/list-view.js +98 -2
  27. package/dist/lanes/meta.js +36 -10
  28. package/dist/lanes/model-contract.js +7 -6
  29. package/dist/lanes/model.js +154 -10
  30. package/dist/lanes/organization.js +67 -7
  31. package/dist/lanes/runtime-data-contract.js +4 -2
  32. package/dist/lanes/runtime-data.js +395 -41
  33. package/dist/lanes/workflow-contract.js +2 -2
  34. package/dist/lanes/workflow.js +381 -11
  35. package/dist/model-scaffold.js +11 -3
  36. package/dist/path-projection.js +168 -31
  37. package/dist/read-shapes.js +31 -11
  38. package/dist/recipes/field.js +16 -3
  39. package/dist/recipes/form.js +2 -1
  40. package/dist/recipes/runtime-data.js +58 -8
  41. package/dist/recipes/workflow.js +86 -1
  42. package/dist/refusal-guidance.js +18 -6
  43. package/dist/rollback-spec.js +41 -2
  44. package/dist/rollback.js +49 -11
  45. package/dist/script.js +72 -12
  46. package/dist/skills.js +91 -3
  47. package/dist/tools.js +113 -1
  48. package/dist/wire.js +13 -1
  49. package/dist/write-lock.js +92 -4
  50. package/dist/write.js +129 -16
  51. package/package.json +3 -3
  52. package/skills/omc-business-configuration.md +1 -1
  53. package/skills/omc-capabilities.md +5 -4
  54. package/skills/omc-playbook.md +41 -0
  55. package/skills/omc-refusals.md +58 -6
  56. package/skills/omc-tool-selection.md +2 -0
  57. package/skills/omc-write-hazards.md +3 -2
@@ -4,7 +4,8 @@ import { capabilityArg, environmentArg } from "../args.js";
4
4
  import { governedRead } from "../read.js";
5
5
  import { writeTool } from "../write.js";
6
6
  import { defineTool, ToolRefusalError } from "../tool-types.js";
7
- import { wireCall, WireError } from "../wire.js";
7
+ import { wireCall, wireRows, WireError } from "../wire.js";
8
+ import { mapBounded } from "../support.js";
8
9
  import { DICTIONARY_LANE_CONTRACT } from "./dictionary-contract.js";
9
10
  const LANE = DICTIONARY_LANE_CONTRACT.lane;
10
11
  const TOOLS = DICTIONARY_LANE_CONTRACT.tools;
@@ -244,7 +245,20 @@ export function registerDictionaryLane(tools) {
244
245
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
245
246
  requireExactlyOne(args.id, args.code);
246
247
  const data = await wireCall(session, { path: args.id ? ENDPOINTS.dictionaryGet : ENDPOINTS.dictionaryGetByCode, query: args.id ? { id: args.id } : { code: args.code } });
247
- return { dictionary: dictionaryView(data.data, { full: args.full }) };
248
+ const dictionary = dictionaryView(data.data, { full: args.full });
249
+ // r29e F5/F7:头读面以前不含 records;给一个低字节 recordsCount,明细仍走 dictionary.records。
250
+ let recordsCount;
251
+ try {
252
+ const code = String(data.data?.code ?? args.code ?? "");
253
+ if (code) {
254
+ const all = (await wireCall(session, { path: ENDPOINTS.dictionaryRecordsByCode, query: { code, searchWord: "", page: "1", size: "1" } })).data;
255
+ recordsCount = Number(all?.totalElements ?? 0);
256
+ }
257
+ }
258
+ catch {
259
+ recordsCount = undefined;
260
+ }
261
+ return { dictionary, ...(recordsCount !== undefined ? { recordsCount } : {}) };
248
262
  }, { full: args.full }),
249
263
  }));
250
264
  tools.push(defineTool({
@@ -257,11 +271,17 @@ export function registerDictionaryLane(tools) {
257
271
  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) },
258
272
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
259
273
  requireExactlyOne(args.id, args.code);
260
- const projectRows = (rows) => sortRecordsBySortKey(rows).map((row) => recordView(row, { full: args.full }));
274
+ // r29e F9:enabledOnly / parentId 以前回裸数组,默认面回分页壳——同一键
275
+ // 形状翻转让 `--jq records[].code` 在非 enabledOnly 时直接炸。统一恒为
276
+ // `{records:{totalElements,content[]}, total}`;enabledOnly 只过滤 content。
277
+ const asPage = (rows) => {
278
+ const content = sortRecordsBySortKey(rows).map((row) => recordView(row, { full: args.full }));
279
+ return { totalElements: content.length, totalPages: 1, content };
280
+ };
261
281
  if (args.parentId) {
262
282
  const data = await wireCall(session, { path: ENDPOINTS.dictionaryParentRecords, query: { parentId: args.parentId } });
263
- const records = Array.isArray(data.data) ? projectRows(data.data) : [];
264
- return { parentId: args.parentId, records, total: records.length, orderNote: RECORDS_ORDER_NOTE };
283
+ const page = asPage(Array.isArray(data.data) ? data.data : []);
284
+ return { parentId: args.parentId, records: page, total: Number(page.totalElements ?? 0), orderNote: RECORDS_ORDER_NOTE };
265
285
  }
266
286
  if (args.enabledOnly) {
267
287
  const dictionary = args.id ? { id: args.id } : (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code: args.code } })).data;
@@ -269,8 +289,8 @@ export function registerDictionaryLane(tools) {
269
289
  if (!id)
270
290
  throw new WireError("business", "dictionary code not found", 301001);
271
291
  const data = await wireCall(session, { path: ENDPOINTS.dictionaryEnabledRecords, query: { dicId: id } });
272
- const records = Array.isArray(data.data) ? projectRows(data.data) : [];
273
- return { dictionaryId: id, enabledOnly: true, records, total: records.length, orderNote: RECORDS_ORDER_NOTE };
292
+ const page = asPage(Array.isArray(data.data) ? data.data : []);
293
+ return { dictionaryId: id, enabledOnly: true, records: page, total: Number(page.totalElements ?? 0), orderNote: RECORDS_ORDER_NOTE };
274
294
  }
275
295
  const path = args.code ? ENDPOINTS.dictionaryRecordsByCode : ENDPOINTS.dictionaryRecords;
276
296
  const query = args.code
@@ -330,7 +350,14 @@ export function registerDictionaryLane(tools) {
330
350
  readback.intentUnmet = true;
331
351
  readback.intentUnmetReason = "records:[] 不会删除既有记录(平台无字典/记录删除端点,save 是增量 upsert);请用 dictionary.status 逐条停用。";
332
352
  }
333
- return { performed: { errcode: performed.errcode }, readback };
353
+ return {
354
+ performed: { errcode: performed.errcode },
355
+ readback,
356
+ // r30a F23: say it on the success receipt (budget-tight; not in describe prose).
357
+ irreversible: args.id
358
+ ? "平台无字典删除端点;更新可再 dictionary.save,删除只能 dictionary.status 停用"
359
+ : "平台无字典删除端点,只能停用(dictionary.status target=dictionary status=false)",
360
+ };
334
361
  },
335
362
  rollback: dictionaryRollbackSpec("dictionary.save"),
336
363
  });
@@ -363,6 +390,171 @@ export function registerDictionaryLane(tools) {
363
390
  return { performed: { errcode: performed.errcode, ids: args.ids }, readback: { ids: args.ids, initialUsed: true } };
364
391
  },
365
392
  });
393
+ // r29e F5:12 个 dictionary 工具以前没有「谁引用了这个字典」读面。
394
+ // 扫描默认应用模型的草稿字段;上限 50 + truncated,避免 O(租户) 不可裁剪。
395
+ const REFERENCES_LIMIT = 50;
396
+ const REFERENCES_MODEL_CAP = 40;
397
+ tools.push(defineTool({
398
+ name: "dictionary.references",
399
+ layer: "low",
400
+ kind: "read",
401
+ lane: LANE,
402
+ scope: TOOLS["dictionary.references"].scope,
403
+ description: TOOLS["dictionary.references"].description,
404
+ inputSchema: {
405
+ environment: environmentArg,
406
+ capability: capabilityArg,
407
+ id: z.string().optional().describe("字典 id(与 code 二选一)"),
408
+ code: z.string().optional().describe("字典 code(与 id 二选一)"),
409
+ schemaCode: z.string().min(1).optional().describe("只扫这一个模型的字段(大租户下比全扫快得多,也避免「没扫到」的歧义)"),
410
+ limit: z.number().int().min(1).max(REFERENCES_LIMIT).default(REFERENCES_LIMIT).describe(`最多返回多少引用(默认 ${REFERENCES_LIMIT};超出 truncated:true)`),
411
+ },
412
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
413
+ requireExactlyOne(args.id, args.code);
414
+ let dictionaryId = args.id ?? "";
415
+ let dictionaryCode = args.code ?? "";
416
+ if (args.code) {
417
+ const header = (await wireCall(session, { path: ENDPOINTS.dictionaryGetByCode, query: { code: args.code } })).data;
418
+ // 「字典不存在」与「没人引用它」必须分得开:前者是调用错误(拼错 code),
419
+ // 后者才是答案。旧实现直接拿 args.code 兜底,拼错会回 count:0。
420
+ if (header === null || String(header.id ?? "") === "")
421
+ throw new ToolRefusalError({
422
+ status: "refused",
423
+ blocker: "dictionary-not-found",
424
+ code: args.code,
425
+ message: `字典 ${args.code} 不存在(dictionary/get_by_code 回空)——拼写错误会与「没有引用」混为一谈;先用 omc call dictionary.list 核对 code`,
426
+ nextCommand: `omc call dictionary.list --args '${JSON.stringify({})}'`,
427
+ });
428
+ dictionaryId = String(header.id ?? "");
429
+ dictionaryCode = String(header.code ?? args.code);
430
+ }
431
+ else if (args.id) {
432
+ const header = (await wireCall(session, { path: ENDPOINTS.dictionaryGet, query: { id: args.id } })).data;
433
+ dictionaryId = String(header?.id ?? args.id);
434
+ dictionaryCode = String(header?.code ?? "");
435
+ }
436
+ if (!dictionaryId && !dictionaryCode) {
437
+ throw new ToolRefusalError({ status: "refused", blocker: "dictionary-target-required", message: "dictionary.references:字典不存在或 id/code 无效" });
438
+ }
439
+ const models = (args.schemaCode !== undefined
440
+ ? [{ code: args.schemaCode }]
441
+ : wireRows((await wireCall(session, { path: ENDPOINTS.modelList, query: {} })).data)
442
+ .filter((row) => row.type === "BizModel" && typeof row.code === "string" && String(row.code).length > 0)
443
+ .slice(0, REFERENCES_MODEL_CAP));
444
+ const limit = args.limit ?? REFERENCES_LIMIT;
445
+ const refs = [];
446
+ let truncated = false;
447
+ // 4 路并发读字段面:40 个模型串行要 40 次往返,大租户下会直接超时。
448
+ const perModel = await mapBounded(models, 4, async (model) => {
449
+ const schemaCode = String(model.code);
450
+ let fields = [];
451
+ try {
452
+ fields = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "false" } })).data);
453
+ }
454
+ catch {
455
+ return { schemaCode, hits: [] };
456
+ }
457
+ const hits = [];
458
+ for (const field of fields) {
459
+ if (refs.length >= limit) {
460
+ truncated = true;
461
+ break;
462
+ }
463
+ const optionsRaw = field.options;
464
+ let options;
465
+ if (typeof optionsRaw === "string" && optionsRaw.trim()) {
466
+ try {
467
+ options = JSON.parse(optionsRaw);
468
+ }
469
+ catch {
470
+ continue;
471
+ }
472
+ }
473
+ else if (optionsRaw && typeof optionsRaw === "object" && !Array.isArray(optionsRaw)) {
474
+ options = optionsRaw;
475
+ }
476
+ else
477
+ continue;
478
+ // 字典引用在平台上**有两种存储形状**,少认一种就会把「有引用」报成 0:
479
+ // ① 设计器形状:options.optionsSet = {optionsType:"dictionary", dictionary:{checkedDictionary,checkedDictionaryCode}}
480
+ // ② OMC 形状(field.create 传 options.checkedDictionary,也是既有环境里最常见的):
481
+ // options.checkedDictionary = {code}
482
+ // 2026-11-05 真机:只认 ① 时,刚用 OMC 建好的 v24.lv 引用 v_lvl 查不到 → count:0。
483
+ const optionsSet = options.optionsSet;
484
+ let checkedId = "";
485
+ let checkedCode = "";
486
+ if (optionsSet && optionsSet.optionsType === "dictionary") {
487
+ const dictionary = (optionsSet.dictionary ?? {});
488
+ checkedId = String(dictionary.checkedDictionary ?? "");
489
+ checkedCode = String(dictionary.checkedDictionaryCode ?? dictionary.dictionaryCode ?? "");
490
+ }
491
+ const flat = options.checkedDictionary;
492
+ if (!checkedId && !checkedCode && flat !== undefined && flat !== null) {
493
+ const record = typeof flat === "string" ? { code: flat } : flat;
494
+ checkedCode = String(record.code ?? record.checkedDictionaryCode ?? "");
495
+ checkedId = String(record.id ?? record.checkedDictionary ?? "");
496
+ // 设计器存 code,OMC 存 {code}: 两种都要能命中
497
+ if (!checkedId && dictionaryCode !== "" && checkedCode === "")
498
+ checkedId = dictionaryCode;
499
+ }
500
+ if (!checkedId && !checkedCode)
501
+ continue;
502
+ const hit = (dictionaryId !== "" && checkedId === dictionaryId) ||
503
+ (dictionaryCode !== "" && (checkedCode === dictionaryCode || checkedId === dictionaryCode));
504
+ if (!hit)
505
+ continue;
506
+ hits.push({
507
+ schemaCode,
508
+ fieldCode: String(field.code ?? field.propertyCode ?? ""),
509
+ fieldName: String(field.name ?? ""),
510
+ propertyType: field.typeName ?? field.type ?? field.propertyType ?? "",
511
+ });
512
+ }
513
+ return { schemaCode, hits };
514
+ });
515
+ let modelsScanned = 0;
516
+ for (const entry of perModel) {
517
+ modelsScanned += 1;
518
+ for (const hit of entry.hits) {
519
+ if (refs.length >= limit) {
520
+ truncated = true;
521
+ break;
522
+ }
523
+ refs.push(hit);
524
+ }
525
+ if (truncated)
526
+ break;
527
+ }
528
+ if (models.length >= REFERENCES_MODEL_CAP && !truncated)
529
+ truncated = true;
530
+ // count:0 + truncated:true 是**最危险的组合**:读起来像「没有引用」,实际是
531
+ // 「还没扫到」。2026-11-05 真机(本租户 31 个模型、v24 排在 23 位)就撞上:
532
+ // 明明刚建了引用,却回 count:0。没有结论时必须明说「不知道」,不能给 0。
533
+ const answer = truncated
534
+ ? refs.length > 0
535
+ ? "partial"
536
+ : "undetermined"
537
+ : "complete";
538
+ return {
539
+ dictionary: { id: dictionaryId || null, code: dictionaryCode || null },
540
+ references: refs,
541
+ count: refs.length,
542
+ answer,
543
+ truncated,
544
+ modelsScanned,
545
+ modelCap: REFERENCES_MODEL_CAP,
546
+ ...(answer === "undetermined"
547
+ ? {
548
+ warning: `未扫完就到达上限(模型≤${REFERENCES_MODEL_CAP}),count:0 **不代表没有引用**。用 schemaCode 直接反查更快:`
549
+ + `omc call field.list --args '{"schemaCode":"<模型>","full":true}' 看 options.checkedDictionary / options.optionsSet.dictionary`,
550
+ }
551
+ : {}),
552
+ note: answer === "complete"
553
+ ? "引用扫描完成(草稿字段;匹配 options.checkedDictionary 与 options.optionsSet.dictionary 两种形状)"
554
+ : `扫描有上限(模型≤${REFERENCES_MODEL_CAP}、引用≤${limit});结果可能不完整。`,
555
+ };
556
+ }),
557
+ }));
366
558
  tools.push(defineTool({
367
559
  name: "dictionary.parent",
368
560
  layer: "low",
@@ -8,9 +8,10 @@ import { LANES } from "./lane-ids.js";
8
8
  export const ENVIRONMENT_WIRE = Object.freeze({
9
9
  discoveryReads: "applicationList + applicationGroupList + applicationTree(appCode) + modelList(parentId='')",
10
10
  readiness: "readiness {ready, blockers, preconditions, warnings, recovery, facts} derives from the same application/group/profile/version reads; no capability fan-out",
11
- applicationScope: "lists every application the tenant exposes; applicationCode selects one and returns its folders/models",
11
+ applicationScope: "lists every application the tenant exposes; applicationCode selects one and returns its folders/models; modelsCount is selected-application scope (not tenant-wide) — tenantModelsCount sums per-app trees",
12
12
  portalVisibility: "group membership decides portal visibility: the portal renders NAMED groups; an app in the fallback group (code === 'all', case-insensitive) or with an unresolvable groupId is flagged portalHidden:true; move it with app.update {code, groupId}",
13
- fingerprint: "canonical environment fingerprint (environment, origin, application, enterprise) from the authenticated session",
13
+ fingerprint: "canonical environment fingerprint (environment, origin, application, enterprise) from the authenticated session; also returns version evidence (detectedVersion/inAnchorFamily/versionSource) shared with discover_environment and doctor",
14
+ versionEvidence: "detectedVersion + inAnchorFamily + versionSource(=system-config) + versionFamily/anchorVersion [+ versionNotice outside the anchor family]; same face on discover_environment, environment.fingerprint, and doctor",
14
15
  });
15
16
  export const ENVIRONMENT_LANE_CONTRACT = Object.freeze({
16
17
  lane: LANES.environment,
@@ -28,6 +29,7 @@ export const ENVIRONMENT_LANE_CONTRACT = Object.freeze({
28
29
  applicationScope: ENVIRONMENT_WIRE.applicationScope,
29
30
  portalVisibility: ENVIRONMENT_WIRE.portalVisibility,
30
31
  fingerprint: ENVIRONMENT_WIRE.fingerprint,
32
+ versionEvidence: ENVIRONMENT_WIRE.versionEvidence,
31
33
  }),
32
34
  wireBindings: Object.freeze({
33
35
  discoveryReads: Object.freeze([behaviorBinding("test/discovery-group-visibility.test.ts#discover_environment exposes group membership and flags fallback-group apps", assertEnvironmentContract)]),
@@ -35,6 +37,7 @@ export const ENVIRONMENT_LANE_CONTRACT = Object.freeze({
35
37
  applicationScope: Object.freeze([behaviorBinding("test/discovery-group-visibility.test.ts#discover_environment with applicationCode returns that app's models", assertEnvironmentContract)]),
36
38
  portalVisibility: Object.freeze([behaviorBinding("test/discovery-group-visibility.test.ts#discover_environment fails closed: an unresolvable groupId is portalHidden", assertEnvironmentContract)]),
37
39
  fingerprint: Object.freeze([behaviorBinding("test/lane-contracts.test.ts#environment lane contract pins the discovery/fingerprint wire", assertEnvironmentContract)]),
40
+ versionEvidence: Object.freeze([behaviorBinding("test/friction-r30f-version-evidence.test.ts#discover_environment and environment.fingerprint return shared version evidence", assertEnvironmentContract)]),
38
41
  }),
39
42
  evidence: Object.freeze([
40
43
  "src/dbsweep.ts",
@@ -44,20 +47,25 @@ export const ENVIRONMENT_LANE_CONTRACT = Object.freeze({
44
47
  tools: Object.freeze({
45
48
  discover_environment: Object.freeze({
46
49
  scope: "discovery fingerprint",
47
- description: "发现应用、模型、字段与默认表单(只读)。默认列出全部应用及其门户分组归属;传入 applicationCode 时进入该应用并返回其下的模型。每个应用带 groupId/groupCode/groupName 与 portalHidden:落在兜底分组「全部」(code=all) 或分组解析不到的应用标 portalHidden:true——门户按具名分组渲染,这些应用在门户不可见,须用 app.update {code,groupId} 迁入具名分组。",
50
+ description: "发现应用/模型(只读)。默列全部应用及门户分组;给 applicationCode 则进该应用。" +
51
+ "每应用带 groupId/groupCode/groupName/portalHidden(code=all 或分组未解析 → portalHidden:true,需 app.update 迁入具名分组)。" +
52
+ "`modelsCount`=选中应用内(同 model.list);租户求和见 tenantModelsCount/modelsPerApplication。" +
53
+ "回 detectedVersion/inAnchorFamily/versionSource(同源 doctor)。",
48
54
  }),
49
55
  "environment.fingerprint": Object.freeze({
50
56
  scope: "fingerprint",
51
- description: "Canonical environment fingerprint (environment, origin, application, enterprise).",
57
+ description: "environment fingerprint(env/origin/application/enterprise)+ detectedVersion/inAnchorFamily/versionSource。fingerprint 标识「哪套部署」,不是资源清单:模型增删后它不变,基线核对请比 resourceFingerprint。",
52
58
  }),
53
59
  }),
54
60
  });
55
61
  export function assertEnvironmentContract() {
56
62
  assertLaneContract(ENVIRONMENT_LANE_CONTRACT);
57
- assertDescriptionCarries(ENVIRONMENT_LANE_CONTRACT, "discover_environment", ["只读", "applicationCode", "模型", "分组", "portalHidden"]);
63
+ assertDescriptionCarries(ENVIRONMENT_LANE_CONTRACT, "discover_environment", ["只读", "applicationCode", "模型", "分组", "portalHidden", "modelsCount", "tenantModelsCount", "detectedVersion"]);
64
+ if (!ENVIRONMENT_WIRE.applicationScope.includes("tenantModelsCount"))
65
+ throw new Error("environment contract must pin modelsCount selected-app scope vs tenantModelsCount");
58
66
  if (!ENVIRONMENT_WIRE.readiness.includes("no capability fan-out"))
59
67
  throw new Error("environment contract must pin readiness as a bounded projection");
60
- assertDescriptionCarries(ENVIRONMENT_LANE_CONTRACT, "environment.fingerprint", ["fingerprint", "environment"]);
68
+ assertDescriptionCarries(ENVIRONMENT_LANE_CONTRACT, "environment.fingerprint", ["fingerprint", "environment", "detectedVersion"]);
61
69
  if (!ENVIRONMENT_WIRE.discoveryReads.includes("applicationList"))
62
70
  throw new Error("environment contract must pin the discovery reads");
63
71
  if (!ENVIRONMENT_WIRE.applicationScope.includes("applicationCode"))
@@ -66,5 +74,9 @@ export function assertEnvironmentContract() {
66
74
  throw new Error("environment contract must pin the portal-visibility flag");
67
75
  if (!ENVIRONMENT_WIRE.fingerprint.includes("environment"))
68
76
  throw new Error("environment contract must pin the fingerprint fields");
77
+ if (!ENVIRONMENT_WIRE.versionEvidence.includes("detectedVersion"))
78
+ throw new Error("environment contract must pin shared version evidence");
79
+ if (!ENVIRONMENT_WIRE.fingerprint.includes("detectedVersion"))
80
+ throw new Error("environment fingerprint contract must mention version evidence");
69
81
  }
70
82
  //# sourceMappingURL=environment-contract.js.map
@@ -2,7 +2,8 @@ import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
3
  import { capabilityArg, environmentArg } from "../args.js";
4
4
  import { governedRead } from "../read.js";
5
- import { environmentWritable, requiresProductionConfirmation } from "../environment-policy.js";
5
+ import { environmentWritable, requiresProductionConfirmation, versionEvidenceOf } from "../environment-policy.js";
6
+ import { versionFamilyNotice } from "../coverage.js";
6
7
  import { flattenTreeRows } from "../support.js";
7
8
  import { defineTool } from "../tool-types.js";
8
9
  import { wireCall, wireRows } from "../wire.js";
@@ -10,6 +11,17 @@ import { ENVIRONMENT_LANE_CONTRACT } from "./environment-contract.js";
10
11
  import { readProjectCard, writeProjectCard } from "../project-card.js";
11
12
  const ENVIRONMENT_LANE = ENVIRONMENT_LANE_CONTRACT.lane;
12
13
  const ENVIRONMENT_TOOLS = ENVIRONMENT_LANE_CONTRACT.tools;
14
+ /** r30f F2 — doctor / discover / fingerprint share one version-evidence face. */
15
+ function versionEvidenceFor(ctx, session) {
16
+ const detected = session.detectedVersion();
17
+ const notice = detected ? versionFamilyNotice(ctx.manifest, detected) : undefined;
18
+ return versionEvidenceOf({
19
+ detectedVersion: detected,
20
+ versionFamily: ctx.manifest.version_family,
21
+ anchorVersion: ctx.manifest.anchor_version,
22
+ ...(notice ? { versionNotice: notice } : {}),
23
+ });
24
+ }
13
25
  export function registerEnvironmentLane(tools) {
14
26
  tools.push(defineTool({
15
27
  name: "discover_environment",
@@ -32,16 +44,63 @@ export function registerEnvironmentLane(tools) {
32
44
  if (args.refresh !== true && args.applicationCode === undefined && args.modelCode === undefined && args.full !== true) {
33
45
  const cached = readProjectCard(ctx.artifactsDir, String(args.environment ?? session.environmentName ?? ""));
34
46
  if (cached !== undefined) {
35
- // r28b F16(真机 2026-11-05,本轮复现):实时读回给 `modelsCount`,
36
- // 缓存命中给 `modelCount`——同一个键换条路径就换名字,脚本 `r["modelsCount"]`
37
- // 直接 KeyError,基线对比也说不清是模型变了还是口径变了。
38
- // 缓存是加速,不是第二套契约:键名与实时读对齐,旧键保留不破既有调用方。
39
- const { modelCount, ...rest } = cached;
47
+ // r28b F16 / r29f F10:缓存是加速,不是第二套契约。键集必须与冷路径
48
+ // compact 同形;generatedAt / fromCacheHint 只做额外键。
49
+ const cachedExtra = cached;
50
+ const apps = Array.isArray(cached.applications) ? cached.applications : [];
51
+ const groups = Array.isArray(cached.groups) ? cached.groups : [];
52
+ const models = Array.isArray(cached.models)
53
+ ? cached.models.map((row) => ({ schemaCode: row.code, name: row.name }))
54
+ : [];
55
+ const modelCount = typeof cached.modelCount === "number" ? cached.modelCount : models.length;
56
+ const tenantCached = cachedExtra.tenantModelsCount;
57
+ const perAppCached = cachedExtra.modelsPerApplication;
58
+ const resolution = cached.applicationResolution;
59
+ const versionEvidence = versionEvidenceFor(ctx, session);
40
60
  return {
41
- ...rest,
42
- ...(typeof modelCount === "number" ? { modelsCount: modelCount, modelCount } : {}),
43
61
  status: "ok",
62
+ applications: apps,
63
+ applicationsCount: apps.length,
64
+ groups,
65
+ groupsCount: groups.length,
66
+ applicationCode: cached.applicationCode,
67
+ applicationResolution: resolution
68
+ ? { status: "resolved", code: resolution.code, source: resolution.source, requested: resolution.code }
69
+ : { status: "resolved", source: "configured", requested: cached.applicationCode },
70
+ applicationGroups: cached.applicationGroups ?? { status: "resolved" },
71
+ readiness: {
72
+ ready: true,
73
+ blockers: [],
74
+ preconditions: [],
75
+ warnings: [],
76
+ recovery: [],
77
+ facts: { fromCache: true, versionEvidence: versionEvidence.detectedVersion !== null },
78
+ },
79
+ foldersAndModels: null,
80
+ foldersAndModelsCount: 0,
81
+ foldersAndModelsOmitted: true,
82
+ models: null,
83
+ modelsCount: modelCount,
84
+ modelsOmitted: true,
85
+ modelsCountScope: "selected-application",
86
+ modelsCountNote: "modelsCount = 当前选中应用(applicationCode)内的模型数,不是租户全集;租户求和见 tenantModelsCount(各应用树相加,同 code 跨应用各计一次)。",
87
+ ...(typeof tenantCached === "number" ? { tenantModelsCount: tenantCached } : {}),
88
+ ...(perAppCached !== undefined ? { modelsPerApplication: perAppCached } : {}),
89
+ tablesHint: "foldersAndModels/models 已外置(*Omitted:true,不是空表);用 detailArtifactId 的 `omc artifact get <id> --jq <key>` 或同次 call 加 --jq/--extract 取回。" +
90
+ "applications/groups 已内联。modelsCount=选中应用内;租户求和见 tenantModelsCount。",
91
+ fingerprintHint: FINGERPRINT_HINT,
92
+ ...(typeof cached.environmentFingerprint === "string" ? { environmentFingerprint: cached.environmentFingerprint } : {}),
93
+ // r30f F2:缓存命中也回版本证据(与 doctor / fingerprint 同源)。
94
+ detectedVersion: versionEvidence.detectedVersion,
95
+ inAnchorFamily: versionEvidence.inAnchorFamily,
96
+ versionSource: versionEvidence.versionSource,
97
+ versionFamily: versionEvidence.versionFamily,
98
+ anchorVersion: versionEvidence.anchorVersion,
99
+ ...(versionEvidence.versionNotice ? { versionNotice: versionEvidence.versionNotice } : {}),
100
+ // 额外键:不替代冷路径键,只标注缓存来源。
101
+ generatedAt: cached.generatedAt,
44
102
  fromCache: true,
103
+ fromCacheHint: cached.fromCacheHint,
45
104
  };
46
105
  }
47
106
  }
@@ -124,6 +183,24 @@ export function registerEnvironmentLane(tools) {
124
183
  };
125
184
  let foldersAndModels = [];
126
185
  let models = [];
186
+ // r29a F13:modelsCount 只覆盖「当前选中应用」的树,不是租户求和。
187
+ // 额外扫一遍各应用树给 tenantModelsCount,并标注口径,避免 32 vs 71 误判。
188
+ let tenantModelsCount = 0;
189
+ const modelsPerApplication = [];
190
+ for (const app of apps) {
191
+ const appCode = String(app.code ?? "");
192
+ if (!appCode)
193
+ continue;
194
+ try {
195
+ const appTree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode } })).data));
196
+ const count = appTree.filter((row) => String(row.type ?? "").toLowerCase() === "bizmodel").length;
197
+ modelsPerApplication.push({ applicationCode: appCode, count });
198
+ tenantModelsCount += count;
199
+ }
200
+ catch {
201
+ modelsPerApplication.push({ applicationCode: appCode, count: -1 });
202
+ }
203
+ }
127
204
  if (selectedApp) {
128
205
  // Live (2026-09-17) the app tree is NESTED (Folder -> BizModel
129
206
  // children); the previous top-level map dropped every model, so
@@ -149,6 +226,12 @@ export function registerEnvironmentLane(tools) {
149
226
  readiness,
150
227
  foldersAndModels,
151
228
  models,
229
+ // r29a F13:口径标注。modelsCount = 选中应用内模型数(与 model.list 单应用一致);
230
+ // tenantModelsCount = 各应用 model.list 求和(同 schemaCode 跨应用各计一次)。
231
+ modelsCountScope: "selected-application",
232
+ modelsCountNote: "modelsCount = 当前选中应用(applicationCode)内的模型数,不是租户全集;租户求和见 tenantModelsCount(各应用树相加,同 code 跨应用各计一次)。",
233
+ tenantModelsCount,
234
+ modelsPerApplication,
152
235
  };
153
236
  // 项目卡写入(软失败):默认形态(无收窄参数)时缓存模型清单,下次会话一次本地读。
154
237
  if (args.applicationCode === undefined && args.modelCode === undefined && args.full !== true) {
@@ -166,15 +249,26 @@ export function registerEnvironmentLane(tools) {
166
249
  models: discovered.models.slice(0, 300).map((row) => ({ code: String(row.schemaCode), name: String(row.name ?? "") })),
167
250
  modelCount: discovered.models.length,
168
251
  generatedAt: new Date().toISOString(),
252
+ // ProjectCard 未声明扩展键;JSON 落盘后由上方 cache 归一读回。
253
+ tenantModelsCount,
254
+ modelsPerApplication,
169
255
  });
170
256
  }
171
257
  }
258
+ const versionEvidence = versionEvidenceFor(ctx, session);
172
259
  return {
173
260
  ...discovered,
174
261
  // r28k F3:裸 hex 不解自用。指纹是 (env, origin, application, enterprise)
175
262
  // 的规范 sha256——绑定写计划 / intent plan 漂移门 / draft 写前复核 /
176
263
  // 读回缓存键;它不是审批令牌。
177
264
  fingerprintHint: FINGERPRINT_HINT,
265
+ // r30f F2:与 doctor / fingerprint 同源的版本证据(冷路径)。
266
+ detectedVersion: versionEvidence.detectedVersion,
267
+ inAnchorFamily: versionEvidence.inAnchorFamily,
268
+ versionSource: versionEvidence.versionSource,
269
+ versionFamily: versionEvidence.versionFamily,
270
+ anchorVersion: versionEvidence.anchorVersion,
271
+ ...(versionEvidence.versionNotice ? { versionNotice: versionEvidence.versionNotice } : {}),
178
272
  };
179
273
  }, {
180
274
  full: args.full === true,
@@ -199,13 +293,22 @@ export function registerEnvironmentLane(tools) {
199
293
  if (!Array.isArray(value))
200
294
  continue;
201
295
  compact[`${key}Count`] = value.length;
202
- // 保留 key 但置 null + *Omitted,形状上区别于真实空数组 [];
203
- // key 仍在 payload 里,read.ts 不会再补空占位。
296
+ // 2026-11-05(r31a F12 复核 / 全量门抓到):**小结果必须内联**。
297
+ // 旧实现只要走到 compact 就无条件置 null,于是 3 个模型也变成
298
+ // `models:null` + `modelsOmitted:true`——调用方拿不到任何 schemaCode,
299
+ // 而 read.ts 的数组键保形上限是 40 对(同口径)。
300
+ // 规则:≤40 条内联;更多才置 null + *Omitted(形状上区别于真实空数组)。
301
+ const INLINE_LIMIT = 40;
302
+ if (value.length <= INLINE_LIMIT)
303
+ continue;
204
304
  compact[key] = null;
205
305
  compact[`${key}Omitted`] = true;
206
306
  }
307
+ // modelsPerApplication 个位数,内联;口径字段始终保留。
207
308
  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 已内联。";
309
+ compact.tablesHint =
310
+ "foldersAndModels/models 已外置(*Omitted:true,不是空表);用 detailArtifactId 的 `omc artifact get <id> --jq <key>` 或同次 call 加 --jq/--extract 取回。" +
311
+ "applications/groups 已内联。modelsCount=选中应用内;租户求和见 tenantModelsCount。";
209
312
  }
210
313
  if (typeof compact.environmentFingerprint === "string" && compact.fingerprintHint === undefined) {
211
314
  compact.fingerprintHint = FINGERPRINT_HINT;
@@ -223,11 +326,21 @@ export function registerEnvironmentLane(tools) {
223
326
  // r28k F3:用途放首句,避免 summary 在第一个英文句号处截断。
224
327
  description: `${FINGERPRINT_HINT} ${ENVIRONMENT_TOOLS["environment.fingerprint"].description}`,
225
328
  inputSchema: { environment: environmentArg, capability: capabilityArg },
226
- handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => ({
227
- environment: session.environmentName,
228
- fingerprint: session.environmentFingerprint(),
229
- fingerprintHint: FINGERPRINT_HINT,
230
- })),
329
+ handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
330
+ const versionEvidence = versionEvidenceFor(ctx, session);
331
+ return {
332
+ environment: session.environmentName,
333
+ fingerprint: session.environmentFingerprint(),
334
+ fingerprintHint: FINGERPRINT_HINT,
335
+ // r30f F2:指纹面也带回版本证据(与 discover / doctor 同源)。
336
+ detectedVersion: versionEvidence.detectedVersion,
337
+ inAnchorFamily: versionEvidence.inAnchorFamily,
338
+ versionSource: versionEvidence.versionSource,
339
+ versionFamily: versionEvidence.versionFamily,
340
+ anchorVersion: versionEvidence.anchorVersion,
341
+ ...(versionEvidence.versionNotice ? { versionNotice: versionEvidence.versionNotice } : {}),
342
+ };
343
+ }),
231
344
  }));
232
345
  }
233
346
  /** r28k F3 — 指纹用途一句话(回执 + describe 共用,避免两处漂移)。 */
@@ -180,6 +180,7 @@ export const FIELD_LANE_CONTRACT = Object.freeze({
180
180
  scope: "create publish readback",
181
181
  description: describeTool("Update an existing field (bizproperty/update) — merges `options` (and optional `choices`/`relativeCode`) onto the current draft/published record.", [
182
182
  "**不切换 propertyType**:field.update 传 propertyType 会被静默忽略(errcode 0 但类型不变,r6 实证 2026-10-10)——已发布字段改类型只能 field.delete + field.create 重建(含关联 mappings 的同类型对齐场景:人员单选只能映射给人员单选)。choices 只对文本单选族(RADIO/DROPDOWN_BOX 等)合法,传给其它类型会被出门校验拒。",
183
+ "字典-backed 可用 `syncDictionary:true` 重填 useDictionariesData;readback 带 face:\"draft\"。",
183
184
  "Use it to add relation `mappings` (source/target carry-through) or change field options. **关联填充映射的事实源是字段行 `options.mappings`**(真机 2026-10-14 中冶实证:改表单控件属性里的 mappings 会被平台按字段重算覆盖、回读不生效)。改映射的正确链:`field.update` 按完整 options 提交(只改 mappings,其余键 queryCode/displayField/schemaCode/conditions… 原值保留)→ `field.publish` → 已发布表单重派生一次:`form.draft`(**rebuild:false**,保留现有布局与已挂在线JS)重存 → `form.publish` → 回读核对。**不要用 field.update 的 cascadeRepublish 刷已带自定义布局/在线JS 的表单**——它会 rebuild 脚手架,可能动到现有自定义布局(rebuild:false 手动链才安全)。",
184
185
  "A relation's display field is the field-row top-level `relativePropertyCode` (options.displayField is mirrored but not what the runtime reads); the tool rewrites it when you pass a new displayField.",
185
186
  "表单条件(`options.requiredFormula`(必填)/`readonlyCondition`(只读)/`displayFormula`(显示))会**自动配对**一条 `bizdatarule` type3/4/5 行:门户**运行时**读字段 options,表单设计器**设计态** Set/Unset 读 `bizdatarule/list`——只写 options 会让设计器显示 Unset。readback.formConditionSync 报 created/updated/deleted/skipped;清空条件传空串(如 requiredFormula:\"\")会同时删规则行;字段未发布时只写 options 并在 skipped 说明原因。",
@@ -189,7 +190,7 @@ export const FIELD_LANE_CONTRACT = Object.freeze({
189
190
  }),
190
191
  "field.publish": Object.freeze({
191
192
  scope: "create publish readback",
192
- description: "Publish the model's draft fields (bizproperty/publish). **Model-scoped**: one call publishes every draft field of the schema — never call once per field. Optional `code`/`codes` verify which fields landed on the published face; the readback also lists all `publishedCodes`. 瞬态重试(ticket 79):**异常小资源环境下**(实测该 test 环境 MySQL 限 1GB/缓冲池 128MB、I/O 被 Quartz 调度器占满,事故夜 30 个 publish 事务挂起 >30s 最长 193s)publish 走 DDL+序列化器定义同步,失败尝试 DDL 自动提交而标志回滚,留下「MySQL 有列 / 序列化器视图无列」中间态,后续 publish 报 `表单:<t>_<schema>不存在列:<col>` 并逐列自愈。常规资源环境预计不触发;工具仍对该特征自动重试至多 3 次(退避 2s/5s),预算耗尽原样浮出。若仍失败:等 ≥30s 再重发本次 publish,勿重复建字段。与字段 options 形状无关(yst/fwb 发布面 2656 行 org 族字段行无强制键)。",
193
+ description: "Publish the model's draft fields (bizproperty/publish). **Model-scoped**: one call publishes every draft field — never once per field. Optional `code`/`codes` verify published face; readback 回 `publishedCodes`。`codes` 短路须草稿/发布 options 一致,否则真发布。formBinding 可含 dictionaryStale / relation-mappings-empty。瞬态重试(ticket 79):异常小资源下 DDL 中间态自动重试≤3(退避 2s/5s);仍失败等≥30s 再发,勿重复建字段。",
193
194
  }),
194
195
  "field.delete": Object.freeze({
195
196
  scope: "create publish readback",