@stalfh233/omc-cli 0.6.15 → 0.6.17

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.
@@ -19,6 +19,24 @@ const WORKFLOW_LANE = WORKFLOW_LANE_CONTRACT.lane;
19
19
  const WORKFLOW_TOOLS = WORKFLOW_LANE_CONTRACT.tools;
20
20
  const WORKFLOW_ACTIVITY_TYPES = new Set(["START", "END", "CONNECTION", "PARTICIPANT", "SUB_INSTANCE", "CIRCULATE", "SYSTEM_ACTIVITY", "AGENT_ACTIVITY"]);
21
21
  const WORKFLOW_POINT = /^-?\d+(?:\.\d+)?,\s-?\d+(?:\.\d+)?$/;
22
+ /** Keep workitem.list's external envelope aligned with data.list. */
23
+ export function workItemListCompactProjection(result) {
24
+ const compact = {};
25
+ for (const [key, value] of Object.entries(result)) {
26
+ if (key !== "items" || !Array.isArray(value)) {
27
+ if (key !== "ids" && key !== "itemsPreview" && key !== "listArtifactHint")
28
+ compact[key] = value;
29
+ continue;
30
+ }
31
+ const rows = value;
32
+ const ids = rows.map((row) => row.workItemId).filter((id) => typeof id === "string" && id.length > 0);
33
+ compact.itemsCount = rows.length;
34
+ compact.ids = ids;
35
+ compact.itemsPreview = ids.slice(0, 3).map((id) => ({ id }));
36
+ compact.listArtifactHint = "items 已外置(truncatedKeys/omittedKeys 含 items):完整待办见 artifact;ids=本页全部 workItemId,itemsPreview=前 3 个 id 引用。";
37
+ }
38
+ return compact;
39
+ }
22
40
  /**
23
41
  * Conditional branch lines (round7). `routes[]` maps to the platform `Rule`
24
42
  * DTO (Rule.java:28-59), which carries a structured branch condition
@@ -749,6 +767,18 @@ function participantReferenceWarnings(template) {
749
767
  }
750
768
  return warnings;
751
769
  }
770
+ export function approvalByOriginatorWarnings(template) {
771
+ const activities = Array.isArray(template.activities) ? template.activities : [];
772
+ const participants = activities.filter((activity) => String(activity.activityType ?? "") === "PARTICIPANT");
773
+ if (participants.length === 0 || !participants.every((activity) => String(activity.participant ?? "").trim() === "{Originator}"))
774
+ return [];
775
+ const activityCodes = participants.map((activity) => String(activity.activityCode ?? "")).filter((code) => code.length > 0);
776
+ return [{
777
+ code: "approval-by-originator",
778
+ activityCodes,
779
+ message: `所有参与人节点(${activityCodes.join("、")})都指向 {Originator}:该流程会由发起人自审自批;这是允许的单人流程,但请确认该行为符合业务意图。`,
780
+ }];
781
+ }
752
782
  /**
753
783
  * Role participants are filtered by `ouScope ∩ originator's department chain`.
754
784
  * A role whose members all have an empty scope therefore resolves to NOBODY —
@@ -764,12 +794,15 @@ async function assertRoleParticipantScopes(session, body) {
764
794
  ? Object.values(body.activities)
765
795
  : [];
766
796
  const roles = [];
797
+ const functionRoles = [];
767
798
  for (const activity of rows) {
768
799
  if (String(activity.activityType ?? "") !== "PARTICIPANT")
769
800
  continue;
770
801
  const raw = activity.participant;
771
802
  if (typeof raw !== "string" || !raw.trim().startsWith("["))
772
803
  continue;
804
+ const activityCode = String(activity.activityCode ?? "");
805
+ const activityName = String(activity.activityName ?? activity.name ?? activityCode);
773
806
  for (const segment of raw.split("+")) {
774
807
  let parsed;
775
808
  try {
@@ -788,7 +821,86 @@ async function assertRoleParticipantScopes(session, body) {
788
821
  continue;
789
822
  const roleId = String(record.id ?? "");
790
823
  if (roleId)
791
- roles.push({ activityCode: String(activity.activityCode ?? ""), roleId, roleName: String(record.name ?? roleId) });
824
+ roles.push({ activityCode, activityName, roleId, roleName: String(record.name ?? roleId) });
825
+ }
826
+ }
827
+ }
828
+ for (const activity of rows) {
829
+ if (String(activity.activityType ?? "") !== "PARTICIPANT")
830
+ continue;
831
+ const participant = typeof activity.participant === "string" ? activity.participant.trim() : "";
832
+ const match = /^FindUserByRole\s*\(\s*[^,]+?\s*,\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*,\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\)\s*$/.exec(participant);
833
+ if (!match)
834
+ continue;
835
+ const quoted = match[1];
836
+ let roleCode;
837
+ try {
838
+ roleCode = quoted.startsWith('"') ? JSON.parse(quoted) : quoted.slice(1, -1).replace(/\\(['\\])/g, "$1");
839
+ }
840
+ catch {
841
+ continue;
842
+ }
843
+ if (roleCode.trim())
844
+ functionRoles.push({ activityCode: String(activity.activityCode ?? ""), activityName: String(activity.activityName ?? activity.name ?? activity.activityCode ?? ""), roleCode: roleCode.trim() });
845
+ }
846
+ if (functionRoles.length > 0) {
847
+ let listedRoles = [];
848
+ let roleListAvailable = false;
849
+ try {
850
+ const data = (await wireCall(session, { path: ENDPOINTS.organizationRoleList, query: { expandAll: "true" } })).data;
851
+ const flattened = flattenRoleTree(wireRows(data));
852
+ listedRoles = Array.isArray(flattened.roles) ? flattened.roles : [];
853
+ roleListAvailable = true;
854
+ }
855
+ catch {
856
+ // Role existence is advisory when the organization read face is unavailable.
857
+ // Do not turn a transient read failure into a blanket workflow write outage.
858
+ roleListAvailable = false;
859
+ }
860
+ if (roleListAvailable) {
861
+ const byCode = new Map(listedRoles
862
+ .map((role) => [String(role.code ?? ""), role])
863
+ .filter(([code]) => code.length > 0));
864
+ const missing = functionRoles.filter((role) => !byCode.has(role.roleCode));
865
+ if (missing.length > 0) {
866
+ const candidates = (needle) => {
867
+ const lower = needle.toLowerCase();
868
+ const distance = (left, right) => {
869
+ const row = Array.from({ length: right.length + 1 }, (_, index) => index);
870
+ for (let i = 1; i <= left.length; i++) {
871
+ let diagonal = row[0];
872
+ row[0] = i;
873
+ for (let j = 1; j <= right.length; j++) {
874
+ const prior = row[j];
875
+ row[j] = left[i - 1] === right[j - 1] ? diagonal : Math.min(row[j] + 1, row[j - 1] + 1, diagonal + 1);
876
+ diagonal = prior;
877
+ }
878
+ }
879
+ return row[right.length];
880
+ };
881
+ return listedRoles
882
+ .map((role) => {
883
+ const code = String(role.code ?? "");
884
+ const name = String(role.name ?? "");
885
+ const haystack = `${code} ${name}`.toLowerCase();
886
+ const substringRank = haystack.includes(lower) ? 0 : 1;
887
+ const editRank = Math.min(distance(lower, code.toLowerCase()), distance(lower, name.toLowerCase()));
888
+ return { code, name, substringRank, editRank };
889
+ })
890
+ .filter((role) => role.code.length > 0)
891
+ .sort((left, right) => left.substringRank - right.substringRank || left.editRank - right.editRank || left.code.localeCompare(right.code))
892
+ .slice(0, 5)
893
+ .map(({ code, name }) => ({ code, name }));
894
+ };
895
+ const first = missing[0];
896
+ throw new ToolRefusalError({
897
+ status: "refused",
898
+ blocker: "workflow-participant-role-not-found",
899
+ roles: missing,
900
+ candidates: candidates(first.roleCode),
901
+ nextCommand: "omc call org.role.list --args '{}'",
902
+ message: `写前拒绝:节点 ${first.activityCode}(${first.activityName})引用了不存在的角色 ${first.roleCode}。放行会导致待办静默回退给发起人(r9/r10/r15 真机)。请先用 org.role.list 确认真实 role code。`,
903
+ });
792
904
  }
793
905
  }
794
906
  }
@@ -2341,12 +2453,14 @@ export function registerWorkflowLane(tools) {
2341
2453
  return { schemaCode: args.schemaCode, exists: false, note: "该模型没有流程可校验" };
2342
2454
  const template = (args.template ?? {});
2343
2455
  const body = await mergedTemplateBody(session, args.schemaCode ?? "", workflowCode, template, activitiesLackBoundaries(template));
2456
+ await assertRoleParticipantScopes(session, body);
2344
2457
  const result = await wireCall(session, { path: ENDPOINTS.workflowValidate, method: "POST", body });
2345
2458
  const messages = Array.isArray(result.data) ? result.data : [];
2346
2459
  const errors = messages.filter((message) => message.errorLevel === "ERROR");
2347
2460
  const warnings = messages.filter((message) => message.errorLevel !== "ERROR");
2348
2461
  // r31c F23: local reachability / else-coverage — warning only, never flips valid.
2349
2462
  const reachability = workflowReachabilityWarnings(body);
2463
+ const originatorWarnings = approvalByOriginatorWarnings(body);
2350
2464
  const allWarnings = [
2351
2465
  ...warnings,
2352
2466
  ...reachability.map((entry) => ({
@@ -2357,6 +2471,7 @@ export function registerWorkflowLane(tools) {
2357
2471
  name: entry.name,
2358
2472
  problem: entry.problem,
2359
2473
  })),
2474
+ ...originatorWarnings.map((entry) => ({ errorLevel: "WARNING", errCode: entry.code, errContent: entry.message, activityCodes: entry.activityCodes })),
2360
2475
  ];
2361
2476
  return {
2362
2477
  workflowCode,
@@ -2366,6 +2481,7 @@ export function registerWorkflowLane(tools) {
2366
2481
  errors,
2367
2482
  warnings: allWarnings,
2368
2483
  ...(reachability.length > 0 ? { reachabilityWarnings: reachability } : {}),
2484
+ ...(originatorWarnings.length > 0 ? { approvalWarnings: originatorWarnings } : {}),
2369
2485
  };
2370
2486
  }, { target: `workflow.validate:${args.workflowCode ?? args.schemaCode}` }),
2371
2487
  }));
@@ -2468,6 +2584,7 @@ export function registerWorkflowLane(tools) {
2468
2584
  // Evaluate on `body` (post-backfill): the readback may drop the handler
2469
2585
  // keys, and the backfilled permittedAction is exactly what will run.
2470
2586
  const rejectWarnings = rejectToStartCancelWarnings(body);
2587
+ const originatorWarnings = approvalByOriginatorWarnings(body);
2471
2588
  const renamedActivities = renamedActivityPairs(template, data);
2472
2589
  const renamedInPlace = inPlaceRenames(data);
2473
2590
  // Caller sent no routes at all: the draft's edges (branches included) are
@@ -2485,7 +2602,7 @@ export function registerWorkflowLane(tools) {
2485
2602
  const activityNames = Object.fromEntries(rawActivityRows
2486
2603
  .map((row) => [String(row.activityCode ?? ""), effectiveActivityName(row)])
2487
2604
  .filter(([code]) => code !== ""));
2488
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, rollbackHint: `本次改动的 before 快照已记录:omc rollback plan --target workflow:${args.schemaCode}/${args.code} 查看可回退项,omc rollback apply --target … --step <n> 执行(草稿回退后仍需 workflow.publish 才生效)`, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2605
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : rawActivityRows.length, templateType: data.templateType ?? null, activityNames, rollbackHint: `本次改动的 before 快照已记录:omc rollback plan --target workflow:${args.schemaCode}/${args.code} 查看可回退项,omc rollback apply --target … --step <n> 执行(草稿回退后仍需 workflow.publish 才生效)`, ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length || originatorWarnings.length ? { workflowWarnings: [...rejectWarnings, ...originatorWarnings] } : {}), ...(branchEdges.length ? { routesInherited: { edges: branchEdges, hint: "本次未传 routes/edges:以上分支边沿用草稿(含 else 兜底)。要改连线请显式回传完整 routes" } } : {}), ...(renamedInPlace.length ? { renamedInPlace, renamedInPlaceHint: "改名已同 code 原地生效(载体 name_i18n,displayName 才是当前名;activityName 是首建值,平台不再变更)" } : {}), ...(renamedActivities.length ? { renamedActivities, renamedHint: "类型变更铸了新码(上表 from→to)且旧码已失效——后续读写一律用 to;再用旧码 from 会被当作新节点追加" } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2489
2606
  },
2490
2607
  preflight: (args) => {
2491
2608
  // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
@@ -2574,6 +2691,7 @@ export function registerWorkflowLane(tools) {
2574
2691
  const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
2575
2692
  await backfillDesignerDefaults(session, args.schemaCode, body, template);
2576
2693
  assertFirstActivityIsOriginator(template, "workflow.publish", body);
2694
+ await assertRoleParticipantScopes(session, body);
2577
2695
  const unregistered = unregisteredParticipantFunctions(body);
2578
2696
  if (unregistered.length > 0)
2579
2697
  throw new ToolRefusalError({
@@ -2611,7 +2729,8 @@ export function registerWorkflowLane(tools) {
2611
2729
  validateWorkflowRender(data, "workflow.publish readback");
2612
2730
  const participantWarnings = participantReferenceWarnings(data);
2613
2731
  const rejectWarnings = rejectToStartCancelWarnings(data);
2614
- return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length ? { workflowWarnings: rejectWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2732
+ const originatorWarnings = approvalByOriginatorWarnings(data);
2733
+ return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), ...(rejectWarnings.length || originatorWarnings.length ? { workflowWarnings: [...rejectWarnings, ...originatorWarnings] } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
2615
2734
  },
2616
2735
  preflight: (args) => {
2617
2736
  assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");
@@ -3205,7 +3324,7 @@ export function registerWorkflowLane(tools) {
3205
3324
  ...("unfilteredTotal" in page && page.unfilteredTotal !== undefined ? { unfilteredTotal: page.unfilteredTotal } : {}),
3206
3325
  items,
3207
3326
  };
3208
- }),
3327
+ }, { compact: workItemListCompactProjection }),
3209
3328
  }));
3210
3329
  tools.push(defineTool({
3211
3330
  name: "workflow.instanceActivity",
package/dist/mcp-graph.js CHANGED
@@ -59,7 +59,7 @@ const EXPLORE_TOOL = {
59
59
  description: [
60
60
  "一次查全一个目标的引用与影响面(本地索引,毫秒级)。",
61
61
  "输入 query=字段/模型/服务的 code(或规则检索词);kind 可选 field|model|service,不给则三种都查。",
62
- "返回 {index(freshness), references:{kind:[{schemaCode,ruleCode,nodeCode,nodeType,via}]}, impact:{kind:totals}, search:{rules}}。",
62
+ "返回 {index(freshness), references:{kind:[{schemaCode,ruleCode,nodeCode,nodeType,via}]}, impact:{kind:totals}, search:{matches:{model,field,service},rules}}。",
63
63
  "改/删/重命名任何字段、模型、服务之前必查;找\"关于 X 的规则\"拿 code 也走这里。",
64
64
  "索引不含规则条件叶子与流程绑定;freshness=partial 时命中可能不完整(答案里有 gapWarning)。",
65
65
  ].join(" "),
@@ -116,7 +116,15 @@ export async function executeGraphTool(name, rawArgs) {
116
116
  for (const one of kinds) {
117
117
  const ref = queryGraphReferences(one, query);
118
118
  const capped = cap(ref.matches);
119
- references[one] = { count: ref.matches.length, ...capped, ...(ref.gapWarning ? { gapWarning: ref.gapWarning } : {}) };
119
+ // r35a-F4c(真机 2026-11-06):**MCP 面是第三个消费者**——CLI 的 references
120
+ // 与 impact 都带 coverage 了,这里若不显式透传,MCP 用户看到的仍是
121
+ // 「matches:[] + 一句旧 note」,照样会把「表单没扫」误读成「没人引用」。
122
+ references[one] = {
123
+ count: ref.matches.length,
124
+ ...capped,
125
+ ...(ref.coverage ? { coverage: ref.coverage } : {}),
126
+ ...(ref.gapWarning ? { gapWarning: ref.gapWarning } : {}),
127
+ };
120
128
  if (ref.matches.length > 0 || kind !== undefined) {
121
129
  const imp = impactGraph(one, query);
122
130
  impact[one] = {
@@ -124,6 +132,7 @@ export async function executeGraphTool(name, rawArgs) {
124
132
  rules: cap(imp.rules),
125
133
  datarules: cap(imp.datarules),
126
134
  listviews: cap(imp.listviews),
135
+ ...(imp.coverage ? { coverage: imp.coverage } : {}),
127
136
  };
128
137
  }
129
138
  }
@@ -135,9 +144,11 @@ export async function executeGraphTool(name, rawArgs) {
135
144
  index,
136
145
  references,
137
146
  impact,
138
- search: { count: search.rules.length, rules: search.rules },
147
+ search: { count: search.rules.length, rules: search.rules, matches: search.matches, coverage: search.coverage },
139
148
  ...(search.note ? { searchNote: search.note } : {}),
140
- note: "索引不含规则条件叶子与流程绑定;改这些面仍需平台侧核查",
149
+ // 旧句「索引不含规则条件叶子与流程绑定」漏了表单与流程权限(r35a-F4 实证
150
+ // 探针据此差点误删被表单引用的字段)。改为指向结构化 coverage,不再靠散文。
151
+ note: "零命中只代表 references[].coverage.scanned 面没有命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除或无影响",
141
152
  });
142
153
  }
143
154
  return toolError(`未知工具:${name}`);
@@ -94,7 +94,10 @@ const READ_SHAPES = {
94
94
  },
95
95
  "workitem.list": {
96
96
  // Flat rows — no fields bag. Wire id is projected as workItemId.
97
- keys: ["items:array<{workItemId,activityCode,...}>", "total", "count"],
97
+ // r34c-F4(真机 2026-11-06):此前 `{workItemId,activityCode,...}` 的省略号让
98
+ // 探针必须实调一次才敢确定标题键叫 activityName(--full 也一样省略)。
99
+ // 展开真实键集(真机 item 实测);外置信封的 ids/itemsPreview 由 F7 补。
100
+ keys: ["items:array<{workItemId,workflowInstanceId,workflowCode,activityCode,activityName,state,sequenceNo,originator,originatorName,participant,participantName,bizObjectId,schemaCode}>", "total", "count"],
98
101
  notes: ["待办 id 键是 workItemId,不是 id(平铺,不套 fields)"],
99
102
  },
100
103
  "rule.list": {
package/dist/read.js CHANGED
@@ -121,7 +121,10 @@ function externalizedShapePlaceholders(result, dropped) {
121
121
  // 自相矛盾的标记会让调用方以为还得去 artifact 取。
122
122
  if (dropped.length === 0)
123
123
  return {};
124
- const placeholders = { truncatedKeys: [...dropped], truncated: true };
124
+ // Keep the A1 empty/null placeholder shape, but explicitly distinguish it
125
+ // from a genuine empty payload. `omittedKeys` is the machine-readable face;
126
+ // `truncatedKeys` remains the legacy list of payload keys moved to artifact.
127
+ const placeholders = { truncatedKeys: [...dropped], omittedKeys: [...dropped], truncated: true };
125
128
  for (const key of dropped)
126
129
  placeholders[key] = Array.isArray(result[key]) ? [] : null;
127
130
  return placeholders;
@@ -291,6 +291,44 @@ const formulaAggregationCrossModel = Object.freeze({
291
291
  requiresEnvironmentDerivedIds: true,
292
292
  prerequisiteData: { source: "field.list(published:true) ×2(来源模型 + 目标模型)", fields: ["source.model", "source.dataItemCode", "source.matchField", "target.matchField"] },
293
293
  });
294
+ const formulaExpressionSameModel = Object.freeze({
295
+ id: "formula-expression-same-model",
296
+ title: "同模型基础公式字段:FORMULA + EXPRESSION(amount×2)",
297
+ aliases: ["同模型公式", "基础公式字段", "表达式字段", "公式字段", "amount*2"],
298
+ status: "offline-valid",
299
+ tool: "field.create",
300
+ args: {
301
+ environment,
302
+ schemaCode: model,
303
+ code: "total",
304
+ name: "合计",
305
+ propertyType: "FORMULA",
306
+ options: {
307
+ formulaConfig: JSON.stringify({
308
+ resultType: 2,
309
+ formulaType: "EXPRESSION",
310
+ expression: JSON.stringify({ formula: "{amount}*2" }),
311
+ }),
312
+ persistent: true,
313
+ },
314
+ },
315
+ preconditions: ["目标模型已存在;amount 是同一模型上已存在并已发布的数值字段;total 尚不存在"],
316
+ conditionalRequirements: [
317
+ "formulaConfig 是 JSON 字符串,内层 expression 仍是 JSON 字符串:CLI recipe 已代做两层序列化,直接把 args 传给 field.create,不要再手工转义。",
318
+ "这是同模型的基础 EXPRESSION;跨模型汇总请改用 formula-aggregation-cross-model。",
319
+ ],
320
+ priorToolReferences: [
321
+ { tool: "field.list", args: { environment, schemaCode: model, published: true }, purpose: "确认 amount 已发布并确认目标 code 未占用" },
322
+ ],
323
+ dryRun: { supported: true, command: `omc call field.create --env ${environment} --dry-run --args '<recipe.args>'`, notes: "dry-run 校验 FORMULA/EXPRESSION 序列化形状,不创建字段" },
324
+ expectedReadback: { readback: { fieldPresent: true, options: { formulaConfig: "<EXPRESSION: {amount}*2>" } }, next: ["field.publish", "form.draft", "form.publish"] },
325
+ refusalRecovery: [
326
+ { code: "duplicate-field", action: "字段已存在改用 field.update 或换 code" },
327
+ { code: "formula-expression-source-missing", action: "先创建并发布同模型的 amount 数值字段,再创建 total" },
328
+ ...commonRecovery,
329
+ ],
330
+ requiresEnvironmentDerivedIds: false,
331
+ });
294
332
  /**
295
333
  * r29(2026-10-29 中冶终轮)+ 用户领域知识:外部数据(企查查/NC 等)在表单上
296
334
  * 「填表时即带出」的标准模式——两段式组合。此前三轮探针(r24/r28b/r29)都发明
@@ -346,6 +384,6 @@ const externalLookupModel = Object.freeze({
346
384
  });
347
385
  export const FIELD_METADATA = Object.freeze({
348
386
  "field.update": Object.freeze({ recipes: [fieldSerialNumber, formConditionalRequired, formAlwaysReadonly, formConditionalReadonly], relatedTools: ["field.list", "field.publish", "form.draft", "form.publish"] }),
349
- "field.create": Object.freeze({ recipes: [numericTwoDecimals, dictionarySelection, dictionaryMultiSelection, childTable, businessModelDropdown, formulaAggregationCrossModel, externalLookupModel], relatedTools: ["dictionary.names", "dictionary.records", "discover_environment", "field.list", "field.publish", "form.draft", "configure_form", "form.publish"] }),
387
+ "field.create": Object.freeze({ recipes: [numericTwoDecimals, dictionarySelection, dictionaryMultiSelection, childTable, businessModelDropdown, formulaAggregationCrossModel, formulaExpressionSameModel, externalLookupModel], relatedTools: ["dictionary.names", "dictionary.records", "discover_environment", "field.list", "field.publish", "form.draft", "configure_form", "form.publish"] }),
350
388
  });
351
389
  //# sourceMappingURL=field.js.map
package/dist/rule-when.js CHANGED
@@ -11,7 +11,8 @@
11
11
  * 编译目标只用**设计器验证过**的形状(前台生效约束,2026-10-16 用户裁决:后端
12
12
  * 接口大量不拦截,save/publish 通过≠门户生效):
13
13
  * - 预置触发行 Create/Update(设计器原生行,非自定义)
14
- * - 单 UPDATE 节点:filterCondition(查找满足条件的主表数据)+ dataActions(赋值)
14
+ * - 单 UPDATE 节点:dataCondition(输入数据满足条件时执行)+
15
+ * filterCondition(默认按当前输入行定位)+ dataActions(赋值)
15
16
  * - FIXED 字面量 / DYNAMIC 字段引用 / EXPRESSION 公式(含 DATEDIF 语法,均真机验证)
16
17
  * - 布局/路由交给 prepareRuleGraph 注入(points 画布兼容,Ticket 109)
17
18
  *
@@ -23,6 +24,16 @@ import { envelope, requestId, classifyResult, CLI_EXIT_CODES } from "./cli-contr
23
24
  import { writeEnvelope } from "./cli-output.js";
24
25
  import { argvValue } from "./args.js";
25
26
  const WHEN_OPS = { "=": "EQ", "==": "EQ", "!=": "NEQ", ">": "GT", "<": "LT", ">=": "GTEQ", "<=": "LTEQ", "~=": "CT", "!~": "NCT" };
27
+ function parseWhen(raw) {
28
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*(>=|<=|~=|!~|==|!=|=|>|<)\s*([\s\S]+)$/.exec(raw.trim());
29
+ if (!match)
30
+ throw new Error(`--when "${raw}" 解析失败:形如 字段=值 / 字段!=值 / 字段>=数字 / 字段~=文本(包含)`);
31
+ const [, field, op, rawValue] = match;
32
+ if (rawValue.startsWith("="))
33
+ throw new Error(`--when "${raw}" 疑似操作符手误(值以 = 开头):检查是否多打了等号`);
34
+ const value = /^-?\d+(\.\d+)?$/.test(rawValue.trim()) ? Number(rawValue.trim()) : rawValue.trim();
35
+ return { field, op, value };
36
+ }
26
37
  /** 编译 --when/--then DSL 为设计器验证形状的规则图(导出供测试断言)。 */
27
38
  export function composeRuleWhenGraph(options) {
28
39
  if (options.when.length === 0)
@@ -33,17 +44,14 @@ export function composeRuleWhenGraph(options) {
33
44
  for (const raw of options.when) {
34
45
  // 最长操作符优先:>= <= ~= !~ == != > <(操作符两侧允许空格——r34 探针
35
46
  // 实测 `dr_price > 1000` 因空格被拒,属于自然书写)
36
- const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*(>=|<=|~=|!~|==|!=|=|>|<)\s*([\s\S]+)$/.exec(raw.trim());
37
- if (!match)
38
- throw new Error(`--when "${raw}" 解析失败:形如 字段=值 / 字段!=值 / 字段>=数字 / 字段~=文本(包含)`);
39
- const [, field, op, rawValue] = match;
40
- if (rawValue.startsWith("="))
41
- throw new Error(`--when "${raw}" 疑似操作符手误(值以 = 开头):检查是否多打了等号`);
47
+ const { field, op, value } = parseWhen(raw);
42
48
  const opCode = WHEN_OPS[op];
43
49
  if (!opCode)
44
50
  throw new Error(`--when 操作符 "${op}" 不受支持`);
45
- const value = /^-?\d+(\.\d+)?$/.test(rawValue.trim()) ? Number(rawValue.trim()) : rawValue.trim();
46
- leaves.push({ targetSchemaDataItem: field, ruleConditionType: opCode, ruleValueType: "FIXED", value });
51
+ // DataCondition uses DataFilterCondition, not TableCondition:
52
+ // currentSchemaDataItem is the input field and the operator wire key is
53
+ // ruleDataConditionType (ruleConditionType is @JsonIgnore here).
54
+ leaves.push({ currentSchemaDataItem: field, ruleDataConditionType: opCode, ruleValueType: "FIXED", value });
47
55
  }
48
56
  const actions = [];
49
57
  for (const raw of options.then) {
@@ -70,7 +78,11 @@ export function composeRuleWhenGraph(options) {
70
78
  nodes: [
71
79
  { nodeCode: "Start", nodeType: "START" },
72
80
  { nodeCode: "UpdateData1", nodeType: "UPDATE", targetObjectCode: options.schema, inputParam: "_input",
73
- filterCondition: { mainConditions: [leaves], sheetConditions: [] },
81
+ // UPDATE.filterCondition selects rows; it is not an execution gate.
82
+ // Keep the trigger row as the default target and put --when on the
83
+ // platform's real execution-gate field (dataCondition).
84
+ dataCondition: { conditionGroup: [leaves] },
85
+ filterCondition: { mainConditions: [[{ targetSchemaDataItem: "id", ruleConditionType: "EQ", ruleValueType: "DYNAMIC", currentSchemaDataItem: "id" }]], sheetConditions: [] },
74
86
  dataActions: actions },
75
87
  { nodeCode: "End", nodeType: "END" },
76
88
  ],
@@ -130,9 +142,27 @@ export async function cmdRuleWhen(args) {
130
142
  catch (error) {
131
143
  return invalid(error instanceof Error ? error.message : String(error));
132
144
  }
133
- const { result, exitCode } = await runToolCall("rule.save", { args: createArgs, requestId: request, environment });
145
+ const rowScope = {
146
+ mode: "current-row",
147
+ matchedRows: null,
148
+ sampleIds: [],
149
+ note: "当前行由运行时 _input.id 确定;保存面没有具体行 id,无法预先 count,按 fail-open 继续保存",
150
+ };
151
+ // print: () => undefined —— stdout 必须恰好一个 JSON 信封(repo 契约)。默认 print 是
152
+ // console.log,会把工具进度行打到信封前面:任何按行解析 stdout 的管道(agent、
153
+ // 重放脚本)第一行就 json.loads 失败(0.6.17 重放 R8 真机撞上)。approval-flow
154
+ // 等 composer 都覆写了,这里漏了;compute.ts 同病同修。
155
+ const { result, exitCode } = await runToolCall("rule.save", { args: createArgs, requestId: request, environment, print: () => undefined });
156
+ // `rule.save` owns the wire write; enrich this high-level command's receipt
157
+ // with the impact face it can prove from the compiled graph. The generated
158
+ // id=id locator is current-row scope, so no count query is needed and the
159
+ // answer is fail-open rather than inventing a table-wide count.
160
+ const scopedResult = {
161
+ ...result,
162
+ rowScope,
163
+ };
134
164
  const classified = classifyResult(result);
135
- writeEnvelope(envelope(classified.status, result, request, classified.error));
165
+ writeEnvelope(envelope(classified.status, scopedResult, request, classified.error));
136
166
  if (apply && classified.status === "ok")
137
167
  process.stderr.write("[omc] 前台生效验证清单(后端通过≠门户生效):1) omc call rule.enable 启用;2) 造一条命中 --when 的测试数据提交;3) 门户打开该记录核对 --then 字段已变更;4) 再造一条不命中的数据确认不误改;5) 设计器打开本规则确认节点/条件显示正常。\n");
138
168
  return exitCode;
package/dist/skills.js CHANGED
@@ -102,6 +102,19 @@ const SYNTHETIC_SKILLS = Object.freeze({
102
102
  "",
103
103
  ].join("\n"),
104
104
  },
105
+ "batch-write": {
106
+ name: "batch-write",
107
+ description: "批量运行数据写入、形状预检与逐条失败定位",
108
+ content: [
109
+ "# 批量写",
110
+ "",
111
+ "通用批量入口是 `omc call <tool> --args-list '<json数组>'`,逐条执行且默认不中断。",
112
+ "先做零写入形状校验:`omc call data.save --args-list '<json数组>' --preflight`;回执 `shapeOnly:true`,只校验 JSON 形状与 schema 必填键,不做业务语义校验;业务校验要真写一次才知道。大数组请用 shell 变量传入(`--args-list` 暂不读取文件或 stdin)。",
113
+ "支持 `data.save`、`data.delete`、`field.create` 等同一工具逐条批量调用;没有专用 `data.import`,导入数据就是逐条 `data.save`。",
114
+ "失败项回 `row`(1 基)、`index`(0 基)和尽力推断的字段/原因;成功项回输入中的 `code` 等业务标识。",
115
+ "",
116
+ ].join("\n"),
117
+ },
105
118
  });
106
119
  /** Every methodology topic exposed by `omc help`. */
107
120
  export function listSkills() {
@@ -171,6 +171,22 @@ function contextualClosestKey(toolName, key, schemaKeys) {
171
171
  return "bizObjectIds";
172
172
  return undefined;
173
173
  }
174
+ /**
175
+ * r35a-F8(真机 2026-11-06):`field.update` 传 `propertyType` 时,编辑距离
176
+ * 建议到 `propertyEmpty`(风马牛不相及)——探针要「改字段类型」却拿不到任何
177
+ * 可行动信息。字段 type/code 在平台设计态不可原地修改,只能删重建(且会波及
178
+ * 引用),所以这里给的是**替代路径**而不是一个错误参数名。
179
+ */
180
+ const CONTEXTUAL_KEY_GUIDANCE = Object.freeze({
181
+ "field.update": Object.freeze({
182
+ propertyType: "平台不支持原地改字段类型:需 field.delete(先看其 impact)→ field.create;同名 code 的改 type 会丢该列存量值",
183
+ type: "同上:改类型需 field.delete → field.create;改名(显示名)用 name,改 code 无工具(等同删+建)",
184
+ code: "字段 code 不可改:只能 field.delete → field.create(引用面会断,先看 field.delete 的 impact)",
185
+ }),
186
+ "field.create": Object.freeze({
187
+ precision: "precision→options.scale(数值小数位,不是顶层 precision)",
188
+ }),
189
+ });
174
190
  function closestSchemaKey(key, schemaKeys) {
175
191
  if (schemaKeys.has(key))
176
192
  return key;
@@ -218,6 +234,8 @@ export function parseToolInput(tool, input) {
218
234
  const unknown = Object.keys(normalized).filter((key) => !schemaKeys.has(key));
219
235
  if (unknown.length > 0) {
220
236
  const suggestions = unknown.map((key) => `${key}→${KEY_SEMANTIC_HINTS[key] ?? contextualClosestKey(tool.name, key, schemaKeys) ?? closestSchemaKey(key, schemaKeys) ?? "(无相近键,omc describe <tool> 看参数表)"}`);
237
+ // r35a-F8:有替代路径指引时单独列(不是「最接近的合法键」,而是「该走哪条路」)。
238
+ const guidanceLines = unknown.map((key) => CONTEXTUAL_KEY_GUIDANCE[tool.name]?.[key]).filter((hint) => typeof hint === "string" && hint.length > 0);
221
239
  // r25 实证:`publish→(无相近键)` 等于没说——配置与发布是两步,用户要的是
222
240
  // 另一个工具。有语义指引时优先给可执行的下一步。
223
241
  const twoStep = unknown
@@ -228,7 +246,7 @@ export function parseToolInput(tool, input) {
228
246
  // 漏掉发布指引(报告原文:「若用程序消费回执,会选 issues[],就会漏掉」)。
229
247
  // 两条通道必须同源,否则等于修了一半。
230
248
  const issueHintFor = (key) => {
231
- const guidance = TWO_STEP_GUIDANCE[tool.name]?.[key];
249
+ const guidance = TWO_STEP_GUIDANCE[tool.name]?.[key] ?? CONTEXTUAL_KEY_GUIDANCE[tool.name]?.[key];
232
250
  if (guidance !== undefined)
233
251
  return guidance;
234
252
  const semantic = KEY_SEMANTIC_HINTS[key];
@@ -247,8 +265,8 @@ export function parseToolInput(tool, input) {
247
265
  status: "error",
248
266
  blocker: "tool-input-unknown-keys",
249
267
  tool: tool.name,
250
- message: `写面工具不接受未知键(会被静默丢弃):${unknown.join("、")}。${twoStep.length > 0 ? twoStep.join(" ") : `最接近的合法键:${suggestions.join(";")}。参数表:${paramCommand}`}`,
251
- ...(twoStep.length > 0 ? { nextCommand: `omc describe ${tool.name}` } : { nextCommand: paramCommand }),
268
+ message: `写面工具不接受未知键(会被静默丢弃):${unknown.join("、")}。${twoStep.length > 0 ? twoStep.join(" ") : `${guidanceLines.length > 0 ? guidanceLines.join(";") : `最接近的合法键:${suggestions.join(";")}`}。参数表:${paramCommand}`}`,
269
+ ...(twoStep.length > 0 || guidanceLines.length > 0 ? { nextCommand: `omc describe ${tool.name}` } : { nextCommand: paramCommand }),
252
270
  issues: unknown.map((key) => ({ path: [key], code: "unrecognized_key", message: issueHintFor(key) })),
253
271
  });
254
272
  }
@@ -173,13 +173,14 @@ export function diagnoseInstanceActivity(input) {
173
173
  const originator = input.originator ?? null;
174
174
  const fallbackSuspect = originator !== null &&
175
175
  active.some((activity) => activity.participantCount > 0 && activity.participants.every((person) => person.id === originator));
176
+ const unresolved = active.filter((activity) => activity.activityType === "PARTICIPANT" && activity.participantCount === 0).map((activity) => activity.activityCode ?? "?");
176
177
  return {
177
178
  code: "has-active-activity",
178
179
  message: `当前激活节点:${active.map((activity) => `${activity.activityCode}(${activity.activityName ?? ""})`).join("、")}`,
179
180
  checks: fallbackSuspect
180
181
  ? ["若这些节点本应流转给角色或他人,现在却落在发起人身上:参与人解析为空时平台会退回发起人(1.1.25 实测),流程看似推进实为错派。核对参与人写法(参与者对象数组 / FindUserByRole(org, roleCode[, order]))与角色成员。"]
181
182
  : [],
182
- warnings: fallbackSuspect ? ["participant-fallback-to-originator"] : [],
183
+ warnings: [...(fallbackSuspect ? ["participant-fallback-to-originator"] : []), ...unresolved.map((activityCode) => `participant-unresolved:${activityCode}`)],
183
184
  };
184
185
  }
185
186
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.6.15",
3
+ "version": "0.6.17",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "commander": "14.0.3",
58
- "omc-core": "0.6.15",
58
+ "omc-core": "0.6.17",
59
59
  "proper-lockfile": "4.1.2",
60
60
  "write-file-atomic": "6.0.0",
61
61
  "zod": "^3.23.8",
@@ -73,6 +73,6 @@
73
73
  "directory": "packages/omc-cli"
74
74
  },
75
75
  "optionalDependencies": {
76
- "@stalfh233/omc-fake-platform": "0.6.15"
76
+ "@stalfh233/omc-fake-platform": "0.6.17"
77
77
  }
78
78
  }