@stalfh233/omc-cli 0.6.16 → 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,8 @@
19
19
  * per-tool preflight apply exactly as they do for a single call.
20
20
  */
21
21
  import { refusalText } from "./cli-contract.js";
22
+ import { createArtifact } from "./artifacts.js";
23
+ import { artifactFetchHint, artifactRoot } from "./command-transport.js";
22
24
  import { runScript } from "./script.js";
23
25
  function inputBusinessKey(args) {
24
26
  const keys = ["code", "name", "bizObjectId", "id"];
@@ -153,6 +155,12 @@ export async function runBatchCall(options) {
153
155
  };
154
156
  });
155
157
  const entriesInline = entriesSummary.slice(0, ENTRIES_INLINE_LIMIT);
158
+ const warnings = batchWarnings(options.argsList);
159
+ const warningsInline = warnings.slice(0, ENTRIES_INLINE_LIMIT);
160
+ const detailOmitted = entriesSummary.length > entriesInline.length || warnings.length > warningsInline.length;
161
+ const detailArtifact = detailOmitted
162
+ ? createArtifact(artifactRoot(), JSON.stringify({ entries: entriesSummary, warnings }, null, 2) + "\n")
163
+ : undefined;
156
164
  return {
157
165
  exitCode: failed > 0 ? 1 : 0,
158
166
  result: {
@@ -166,10 +174,17 @@ export async function runBatchCall(options) {
166
174
  // 每条一行摘要(index/ok/bizObjectId)——不是完整信封,但足以回答
167
175
  // 「第 3 条建成了吗、它的 id 是什么」。失败项另有 failures 带完整原因。
168
176
  entries: entriesInline,
169
- ...(batchWarnings(options.argsList).length > 0 ? { warnings: batchWarnings(options.argsList) } : {}),
170
- ...(entries.length > entriesInline.length
171
- ? { entriesOmitted: entries.length - entriesInline.length, entriesHint: `条目超过 ${ENTRIES_INLINE_LIMIT} 条,只内联前 ${ENTRIES_INLINE_LIMIT} 条摘要;完整结果走 detailArtifactId / omc artifact get` }
172
- : {}),
177
+ ...(warnings.length > 0 ? {
178
+ warnings: warningsInline,
179
+ ...(warnings.length > warningsInline.length ? { warningsOmitted: warnings.length - warningsInline.length } : {}),
180
+ } : {}),
181
+ ...(entriesSummary.length > entriesInline.length ? { entriesOmitted: entriesSummary.length - entriesInline.length } : {}),
182
+ ...(detailArtifact !== undefined ? {
183
+ detailArtifactId: detailArtifact.artifactId,
184
+ sha256: detailArtifact.sha256,
185
+ fetch: artifactFetchHint(detailArtifact.artifactId),
186
+ entriesHint: `条目或预检警告超过 ${ENTRIES_INLINE_LIMIT} 条,只内联前 ${ENTRIES_INLINE_LIMIT} 条;完整结果见 detailArtifactId`,
187
+ } : {}),
173
188
  // Only failures carry full per-entry detail: echoing 8 full success
174
189
  // envelopes would rebuild exactly the wall of text this command exists
175
190
  // to remove.
@@ -123,7 +123,7 @@ const SURFACES = [
123
123
  },
124
124
  {
125
125
  id: "workflow",
126
- title: "流程模板",
126
+ title: "流程模板 + 组织与权限",
127
127
  lane: LANES.workflow,
128
128
  description: "流程模板生命周期:get_draft/get_published、create、update、publish、bindRule、delete、workitem.list。**含组织/部门/角色/权限范围(org.* 工具)**:部门树、角色组/角色、用户搜索、成员与管理范围(userScope)——流程参与人/可见性的候选来源。",
129
129
  operations: ["create", "update", "publish", "bindRule", "delete"],
@@ -193,7 +193,7 @@ const CAPABILITY_ALIASES = Object.freeze({
193
193
  businessrule: ["business-rules", "B1-businessrule"],
194
194
  "rule-lifecycle": ["rule-lifecycles", "S25-rule-lifecycle"],
195
195
  // r28h F1/F7: org.* lives under workflow; aliases let "我要管部门/组织" hit it.
196
- workflow: ["workflows", "W7-S29-greenfield", "org", "organization", "organisation", "组织", "组织架构"],
196
+ workflow: ["workflows", "W7-S29-greenfield", "org", "organization", "permission", "role", "权限", "角色", "组织"],
197
197
  onlinejs: ["online-js"],
198
198
  programming: ["backend-online-coding", "programming-online-dev", "在线开发", "在线编码"],
199
199
  bizservice: ["bizservices", "B2-registration"],
package/dist/cli.js CHANGED
@@ -1670,7 +1670,10 @@ function cmdGraphReferences(kind, code, transport, requestedEnvironment) {
1670
1670
  function cmdGraphStatus(transport) {
1671
1671
  try {
1672
1672
  const status = readGraphStatus();
1673
- return emitResult({ status: "ok", index: status.index, dbBytes: status.dbBytes, gaps: status.gaps }, transport);
1673
+ // r31a-F16:这里是**第四个**白名单式重建(前三次:compact() 投影、references
1674
+ // 覆盖声明、mcp-graph 的 explore)。白名单每漏一个键就等于没修,且不会被任何
1675
+ // 单测发现——所以改成展开,新增字段自动透传。
1676
+ return emitResult({ status: "ok", ...status }, transport);
1674
1677
  }
1675
1678
  catch (error) {
1676
1679
  if (error instanceof GraphIndexAbsentError)
package/dist/compute.js CHANGED
@@ -189,7 +189,7 @@ export async function cmdCompute(args) {
189
189
  let result;
190
190
  let exitCode;
191
191
  try {
192
- ({ result, exitCode } = await runToolCall("datarule.create", { args: createArgs, requestId: request, environment }));
192
+ ({ result, exitCode } = await runToolCall("datarule.create", { args: createArgs, requestId: request, environment, print: () => undefined }));
193
193
  }
194
194
  catch (error) {
195
195
  const message = error instanceof Error ? error.message : String(error);
@@ -64,7 +64,7 @@ export function relationDisplayFieldCode(type, options) {
64
64
  const displayField = options.displayField;
65
65
  return typeof displayField === "string" && displayField.trim() ? displayField.trim() : undefined;
66
66
  }
67
- export const choicesArg = z.array(z.string()).optional().describe('allowed values for text-choice families (RADIO / CHECKBOX / DROPDOWN_BOX / DROPDOWN_MULTI_BOX), e.g. ["正常","异常","待复检"]。注意:SELECTION(5) 是人员/部门混合选择器,不接受 choices,文本单选请用 DROPDOWN_BOX/RADIO');
67
+ export const choicesArg = z.array(z.string()).optional().describe('allowed values for text-choice families, e.g. ["正常","异常","待复检"]。注意:SELECTION(5) 是人员/部门混合选择器,不接受 choices;文本单选族随版本不同,拒绝信息会列当前 live 集');
68
68
  function jsonString() {
69
69
  return z.string().refine((value) => {
70
70
  if (value === "")
@@ -830,6 +830,43 @@ export function assertFieldFamilyAllowed(args) {
830
830
  // works if the preflight gate is the same gate the write uses.
831
831
  assertNumericalScale(normalized, options);
832
832
  }
833
+ /**
834
+ * Reject the platform's accept-then-drop org-selector+choices shapes before write.
835
+ *
836
+ * 三个组织选择器族同语义(SELECTION 混合 / STAFF_SELECTOR 人员 / DEPARTMENT_SELECTOR
837
+ * 部门):choices 都会被平台静默丢弃,字段建成后写任何 choices 值都 50000 无明细。
838
+ * r37(2026-11-07)之前 SELECTION 走的是 execute 路径的 capability-out-of-coverage
839
+ * (citation.reason 才说不接受 choices),STAFF/DEPARTMENT 走 live 集拒绝——同一语义
840
+ * 三个族两种 blocker。统一收敛到这里:同语义 = 同 blocker(含 options.choices 形态,
841
+ * 那是 F14 真机撞出来的:字段落库、field.list 还回显 choices、运行面才炸)。
842
+ */
843
+ const ORG_SELECTOR_TYPES = new Set([
844
+ FIELD_PROPERTY_TYPES.SELECTION,
845
+ FIELD_PROPERTY_TYPES.STAFF_SELECTOR,
846
+ FIELD_PROPERTY_TYPES.DEPARTMENT_SELECTOR,
847
+ ]);
848
+ export function assertSelectionChoicesAllowed(args) {
849
+ const options = args.options;
850
+ const hasTopLevelChoices = args.choices !== undefined;
851
+ const hasOptionsChoices = options !== null && typeof options === "object" && !Array.isArray(options)
852
+ && Object.prototype.hasOwnProperty.call(options, "choices");
853
+ if (!hasTopLevelChoices && !hasOptionsChoices)
854
+ return;
855
+ const raw = args.propertyType;
856
+ const type = typeof raw === "number" && Number.isInteger(raw)
857
+ ? raw
858
+ : typeof raw === "string" && raw in FIELD_PROPERTY_TYPES
859
+ ? FIELD_PROPERTY_TYPES[raw]
860
+ : undefined;
861
+ if (type === undefined || !ORG_SELECTOR_TYPES.has(type))
862
+ return;
863
+ const family = typeof raw === "string" ? raw : String(type);
864
+ throw new ToolRefusalError({
865
+ status: "refused",
866
+ blocker: "field-choices-family-mismatch",
867
+ message: `${family} 是组织选择器(人员/部门),choices 会被平台静默丢弃;字段虽会建成,但写入任何 choices 值都会返回 50000 且无明细。选人/部门请使用不带 choices 的本族字段(值走引用);文本单选族随版本不同,请先看 omc describe field.create --param propertyType,拒绝信息会列当前 live 集。`,
868
+ });
869
+ }
833
870
  function refuseFieldOptions(reason, message) {
834
871
  const citation = {
835
872
  code: "capability-out-of-coverage",
package/dist/graph.js CHANGED
@@ -46,10 +46,18 @@ function nodeSqlite() {
46
46
  * staleness is handled by reporting pulledAt + identity, refreshed by an
47
47
  * explicit re-pull (no incremental watermarks, no write-back hooks).
48
48
  */
49
- const GRAPH_SCHEMA_VERSION = "4";
49
+ const GRAPH_SCHEMA_VERSION = "5";
50
50
  const STALE_LOCK_MS = 15 * 60 * 1000;
51
51
  export const DEFAULT_PULL_CONCURRENCY = 16;
52
52
  export const MAX_PULL_CONCURRENCY = 32;
53
+ /** 索引超过这个天数就提示刷新(配置类查询,1 天足够新)。 */
54
+ export const STALE_INDEX_DAYS = 1;
55
+ export function indexAgeDays(pulledAtIso, now = Date.now()) {
56
+ const pulled = Date.parse(pulledAtIso);
57
+ if (Number.isNaN(pulled))
58
+ return 0;
59
+ return Math.max(0, Math.floor((now - pulled) / 86_400_000));
60
+ }
53
61
  export class GraphIndexAbsentError extends Error {
54
62
  constructor(message) {
55
63
  super(message);
@@ -179,13 +187,39 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
179
187
  // zero-guard) wedge the scheduler.
180
188
  const scope = await resolveApplicationScope(session, undefined).catch(() => undefined);
181
189
  const applicationCode = scope?.code ?? "";
182
- const schemaCodes = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data))
183
- .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "")
184
- .map((row) => String(row.code))
185
- .sort();
190
+ const gaps = [];
191
+ const treeRows = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
192
+ const modelEntities = treeRows.filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "").map((row) => ({ schemaCode: String(row.code), name: String(row.name ?? row.code) })).sort((a, b) => a.schemaCode.localeCompare(b.schemaCode));
193
+ const schemaCodes = modelEntities.map((row) => row.schemaCode);
194
+ const fields = [];
195
+ const services = [];
196
+ await Promise.all(schemaCodes.map(async (schemaCode) => {
197
+ try {
198
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish: "true" } })).data);
199
+ for (const row of rows) {
200
+ const code = String(row.code ?? row.propertyCode ?? "");
201
+ if (code)
202
+ fields.push({ schemaCode, code, name: String(row.name ?? row.propertyName ?? code) });
203
+ }
204
+ }
205
+ catch (error) {
206
+ gaps.push({ kind: "field-list", ref: schemaCode, note: error instanceof Error ? error.message : String(error) });
207
+ }
208
+ }));
209
+ try {
210
+ await wireCall(session, { path: ENDPOINTS.bizServiceCategoryList });
211
+ const rows = wireRows((await wireCall(session, { path: ENDPOINTS.bizServiceListByCategoryId, query: { categoryId: "" } })).data);
212
+ for (const row of rows) {
213
+ const code = String(row.code ?? "");
214
+ if (code)
215
+ services.push({ code, name: String(row.name ?? code) });
216
+ }
217
+ }
218
+ catch (error) {
219
+ gaps.push({ kind: "service-list", ref: "all", note: error instanceof Error ? error.message : String(error) });
220
+ }
186
221
  const rules = [];
187
222
  const edges = [];
188
- const gaps = [];
189
223
  let parseFailures = 0;
190
224
  let dataruleCount = 0;
191
225
  let viewCount = 0;
@@ -375,6 +409,9 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
375
409
  },
376
410
  rules,
377
411
  edges,
412
+ models: modelEntities,
413
+ fields: fields.sort((a, b) => `${a.schemaCode}/${a.code}`.localeCompare(`${b.schemaCode}/${b.code}`)),
414
+ services: services.sort((a, b) => a.code.localeCompare(b.code)),
378
415
  gaps,
379
416
  scanned: { models: schemaCodes.length, rules: rules.length, datarules: dataruleCount, views: viewCount, parseFailures },
380
417
  timings: { listMs, readMs, totalMs: Date.now() - totalStart },
@@ -489,7 +526,10 @@ export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCU
489
526
  via TEXT NOT NULL
490
527
  );
491
528
  CREATE INDEX edges_lookup ON edges(kind, code);
492
- CREATE TABLE gaps (kind TEXT NOT NULL, ref TEXT NOT NULL, note TEXT);
529
+ CREATE TABLE gaps (kind TEXT NOT NULL, ref TEXT NOT NULL, note TEXT);
530
+ CREATE TABLE models (schema_code TEXT PRIMARY KEY, name TEXT NOT NULL);
531
+ CREATE TABLE fields (schema_code TEXT NOT NULL, code TEXT NOT NULL, name TEXT NOT NULL);
532
+ CREATE TABLE services (code TEXT PRIMARY KEY, name TEXT NOT NULL);
493
533
  `);
494
534
  const insertMeta = db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)");
495
535
  // `applicationResolution` is an object; String(value) would persist
@@ -506,12 +546,21 @@ export async function publishGraphPull(session, concurrency = DEFAULT_PULL_CONCU
506
546
  const insertGap = db.prepare("INSERT INTO gaps (kind, ref, note) VALUES (?, ?, ?)");
507
547
  for (const gap of data.gaps)
508
548
  insertGap.run(gap.kind, gap.ref, gap.note ?? null);
549
+ const insertModel = db.prepare("INSERT INTO models (schema_code, name) VALUES (?, ?)");
550
+ for (const row of data.models)
551
+ insertModel.run(row.schemaCode, row.name);
552
+ const insertField = db.prepare("INSERT INTO fields (schema_code, code, name) VALUES (?, ?, ?)");
553
+ for (const row of data.fields)
554
+ insertField.run(row.schemaCode, row.code, row.name);
555
+ const insertService = db.prepare("INSERT INTO services (code, name) VALUES (?, ?)");
556
+ for (const row of data.services)
557
+ insertService.run(row.code, row.name);
509
558
  // FTS5 mirrors rules (code+name) for `graph search`; one rebuild after
510
559
  // the bulk insert is cheaper than per-row triggers on a wholesale rebuild.
511
560
  db.exec(`
512
- CREATE VIRTUAL TABLE rules_fts USING fts5(rule_code, name, content='rules', content_rowid='rowid');
513
- INSERT INTO rules_fts(rules_fts) VALUES('rebuild');
514
- `);
561
+ CREATE VIRTUAL TABLE rules_fts USING fts5(rule_code, name, content='rules', content_rowid='rowid');
562
+ INSERT INTO rules_fts(rules_fts) VALUES('rebuild');
563
+ `);
515
564
  db.exec("COMMIT;");
516
565
  }
517
566
  finally {
@@ -544,6 +593,11 @@ function openGraphIndex() {
544
593
  throw new GraphIndexAbsentError(`索引 schema 版本不匹配(${meta.schemaVersion} ≠ ${GRAPH_SCHEMA_VERSION});重跑 omc graph pull`);
545
594
  const ruleCount = db.prepare("SELECT COUNT(*) AS n FROM rules").get().n;
546
595
  const modelCount = db.prepare("SELECT COUNT(DISTINCT schema_code) AS n FROM rules").get().n;
596
+ const entityCounts = {
597
+ models: db.prepare("SELECT COUNT(*) AS n FROM models").get().n,
598
+ fields: db.prepare("SELECT COUNT(*) AS n FROM fields").get().n,
599
+ services: db.prepare("SELECT COUNT(*) AS n FROM services").get().n,
600
+ };
547
601
  const edgeRows = db.prepare("SELECT kind, COUNT(*) AS n FROM edges GROUP BY kind").all();
548
602
  const edges = {};
549
603
  for (const row of edgeRows)
@@ -555,6 +609,8 @@ function openGraphIndex() {
555
609
  byKind[row.kind] = row.n;
556
610
  gapCount += row.n;
557
611
  }
612
+ const pulledAtIso = meta.pulledAtIso ?? "";
613
+ const ageDays = indexAgeDays(pulledAtIso);
558
614
  return {
559
615
  db,
560
616
  summary: {
@@ -564,10 +620,17 @@ function openGraphIndex() {
564
620
  // such key, so this stays optional rather than failing the read.
565
621
  ...(meta.applicationResolution ? { applicationResolution: parseApplicationResolution(meta.applicationResolution) } : {}),
566
622
  identityFingerprint: meta.identityFingerprint ?? "",
567
- pulledAtIso: meta.pulledAtIso ?? "",
623
+ pulledAtIso,
568
624
  freshness: gapCount === 0 ? "complete" : "partial",
625
+ ageDays,
626
+ // r31a-F16:旧索引 + complete 标签 = 静默错答。给可直接照抄的刷新命令,
627
+ // 并明确「complete 只指扫描无缺口」。
628
+ ...(ageDays >= STALE_INDEX_DAYS
629
+ ? { staleWarning: `索引是 ${ageDays} 天前的快照(pulledAt ${pulledAtIso});freshness:"complete" 只表示扫描无缺口、不表示最新。先 \`omc graph pull --env ${meta.environmentName ?? "<env>"}\` 再查,或给结论加时效限定` }
630
+ : {}),
569
631
  models: modelCount,
570
632
  rules: ruleCount,
633
+ entities: entityCounts,
571
634
  edges,
572
635
  gaps: { count: gapCount, byKind },
573
636
  },
@@ -578,6 +641,17 @@ function openGraphIndex() {
578
641
  throw error instanceof GraphIndexAbsentError ? error : new GraphIndexAbsentError(`索引不可读(schema 不匹配或文件损坏):${error instanceof Error ? error.message : String(error)};重跑 omc graph pull`);
579
642
  }
580
643
  }
644
+ /**
645
+ * r35a-F4 / r35a-F4b(真机 2026-11-06):探针拿 `matches:[]` 差点把一个**被表单引用**
646
+ * 的字段判成「可安全删除」,紧接着发现 `graph impact` 的 `totals` 全 0 也有同样误读风险。
647
+ * 三个面(CLI references / CLI impact / MCP explore)必须共用同一份声明——
648
+ * 分散定义就会被漏掉一处(MCP 面第一版就漏了)。
649
+ */
650
+ export const GRAPH_COVERAGE = Object.freeze({
651
+ scanned: Object.freeze(["businessrule", "datarule", "listview"]),
652
+ notScanned: Object.freeze(["form", "workflow.permissions", "workflow.bindings", "onlinejs", "rule.conditions"]),
653
+ note: "零命中/全零 totals 只代表 scanned 面没有命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除或无影响",
654
+ });
581
655
  /**
582
656
  * Zero-hit disambiguation (2026-10-31 capability-test friction, hit twice in
583
657
  * one session): querying `impact field mcc_supplier` returns an empty result
@@ -611,11 +685,7 @@ export function queryGraphReferences(kind, code) {
611
685
  // 字段判成「可安全删除」——note 只说了「不含规则条件叶子/流程绑定」,从没
612
686
  // 明说表单与流程权限表不在扫描范围。散文字段不承担安全契约:这里给结构化
613
687
  // coverage,让「没人引用」与「这一面没扫」在机器可读层面可分。
614
- const coverage = {
615
- scanned: ["businessrule", "datarule", "listview"],
616
- notScanned: ["form", "workflow.permissions", "workflow.bindings", "onlinejs", "rule.conditions"],
617
- note: "matches:[] 只代表 scanned 面零命中;notScanned 面(尤其 form 与 workflow.permissions)未扫描,不可据此判定可安全删除",
618
- };
688
+ const coverage = GRAPH_COVERAGE;
619
689
  return {
620
690
  kind,
621
691
  code,
@@ -643,7 +713,14 @@ export function readGraphStatus() {
643
713
  const { db, summary } = openGraphIndex();
644
714
  try {
645
715
  const gaps = db.prepare("SELECT kind, ref, note FROM gaps ORDER BY kind, ref LIMIT 50").all();
646
- return { index: summary, dbBytes: statSync(graphDbPath()).size, gaps };
716
+ const dbPath = graphDbPath();
717
+ return {
718
+ index: summary,
719
+ dbBytes: statSync(dbPath).size,
720
+ dbPath,
721
+ dbId: sha256(dbPath).slice(0, 12),
722
+ gaps,
723
+ };
647
724
  }
648
725
  finally {
649
726
  db.close();
@@ -661,6 +738,11 @@ export function searchGraphRules(term, limit = 20) {
661
738
  try {
662
739
  const tokens = term.trim().split(/\s+/).filter((token) => token !== "");
663
740
  let matches = [];
741
+ const entityMatches = {
742
+ model: { count: 0, total: 0, items: [] },
743
+ field: { count: 0, total: 0, items: [] },
744
+ service: { count: 0, total: 0, items: [] },
745
+ };
664
746
  if (tokens.length > 0) {
665
747
  const quoted = tokens.map((token, index) => `"${token.replaceAll('"', '""')}"${index === tokens.length - 1 ? "*" : ""}`).join(" ");
666
748
  const rows = db
@@ -683,13 +765,24 @@ export function searchGraphRules(term, limit = 20) {
683
765
  refs,
684
766
  };
685
767
  });
768
+ const searchEntity = (countSql, rowsSql, params, map) => {
769
+ const total = Number(db.prepare(countSql).get(...params).n);
770
+ const rows = db.prepare(rowsSql).all(...params, limit);
771
+ return { count: rows.length, total, items: rows.map(map), ...(rows.length < total ? { truncated: true } : {}) };
772
+ };
773
+ entityMatches.model = searchEntity("SELECT COUNT(*) AS n FROM models WHERE instr(name, ?) > 0 OR instr(schema_code, ?) > 0", "SELECT schema_code, name FROM models WHERE instr(name, ?) > 0 OR instr(schema_code, ?) > 0 ORDER BY schema_code LIMIT ?", [term.trim(), term.trim()], (row) => ({ schemaCode: row.schema_code, name: row.name }));
774
+ entityMatches.field = searchEntity("SELECT COUNT(*) AS n FROM fields WHERE instr(name, ?) > 0 OR instr(code, ?) > 0", "SELECT schema_code, code, name FROM fields WHERE instr(name, ?) > 0 OR instr(code, ?) > 0 ORDER BY schema_code, code LIMIT ?", [term.trim(), term.trim()], (row) => ({ schemaCode: row.schema_code, code: row.code, name: row.name }));
775
+ entityMatches.service = searchEntity("SELECT COUNT(*) AS n FROM services WHERE instr(name, ?) > 0 OR instr(code, ?) > 0", "SELECT code, name FROM services WHERE instr(name, ?) > 0 OR instr(code, ?) > 0 ORDER BY code LIMIT ?", [term.trim(), term.trim()], (row) => ({ code: row.code, name: row.name }));
686
776
  }
687
777
  const gapped = summary.gaps.count > 0;
688
778
  return {
689
779
  term,
690
780
  rules: matches,
781
+ matches: entityMatches,
691
782
  index: summary,
692
- ...(matches.length === 0
783
+ coverage: { scanned: ["rule.code", "rule.name", "model.code", "model.name", "field.code", "field.name", "service.code", "service.name"], notScanned: ["rule.body", "form", "workflow.permissions"], note: "检索覆盖规则与实体的 code/name;规则体、表单与流程权限未扫描" },
784
+ ...(term.trim() !== "" && !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(term.trim()) ? { note: "疑似业务词;本工具同时检索实体 name/code 与规则 code/name,引用明细请使用匹配到的 code 再查 references/impact" } : {}),
785
+ ...(matches.length === 0 && entityMatches.model.total === 0 && entityMatches.field.total === 0 && entityMatches.service.total === 0
693
786
  ? {
694
787
  note: tokens.length === 0
695
788
  ? "检索词为空"
@@ -767,11 +860,12 @@ export function impactGraph(kind, code) {
767
860
  listviews: listViewHits.slice(0, 20).map((hit) => ({ schemaCode: hit.schema_code, viewCode: hit.view_code ?? "", via: hit.via })),
768
861
  totals: { rules: hop1.length, datarules: dataruleHits.length, listviews: listViewHits.length, touchedServices: services.size, touchedModels: models.size, touchedFields: fields.size },
769
862
  index: summary,
863
+ coverage: GRAPH_COVERAGE,
770
864
  ...(hop1.length === 0 && dataruleHits.length === 0 && listViewHits.length === 0
771
865
  ? {
772
866
  note: (gapped
773
867
  ? "零命中,但索引存在扫描缺口——不可作为「无影响」证明;先 omc graph pull 重建再查"
774
- : "零命中=索引覆盖范围内没有引用它(规则面+数据规则面+视图列/排序/条件面;流程绑定暂不在索引内)") +
868
+ : "零命中=仅 scanned 面(规则/数据规则/视图)没有影响;form(表单控件)与 workflow.permissions 未扫描,见 coverage.notScanned——不可据此判定「删除无影响」") +
775
869
  (zeroHitKindHint(db, kind, code, "impact") ?? ""),
776
870
  }
777
871
  : {}),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
3
  import { fieldChangeWireBody, fieldUpdateWireBody, prepareFieldChange, prepareFieldUpdate } from "../field-change.js";
4
- import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, } from "../field-families.js";
4
+ import { aggregationSelfSchemaMessage, aggregationTargetsOwnSchema, choicesArg, childColumnsOf, FIELD_PROPERTY_TYPES, fieldOptionsArg, fieldTypeLabel, numericalScaleWarning, propertyTypeArg, relativeCodeArg, stripAggregationPrefix, subFieldArg, SYSTEM_FIELD_CODES, assertFieldFamilyAllowed, assertSelectionChoicesAllowed, } from "../field-families.js";
5
5
  import { capabilityArg, environmentArg } from "../args.js";
6
6
  import { governedRead } from "../read.js";
7
7
  import { writeTool } from "../write.js";
@@ -442,6 +442,10 @@ export function registerFieldLane(tools) {
442
442
  },
443
443
  };
444
444
  },
445
+ preflight: (args) => {
446
+ assertSelectionChoicesAllowed(args);
447
+ assertFieldFamilyAllowed(args);
448
+ },
445
449
  rollback: fieldRollbackSpec("field.create"),
446
450
  });
447
451
  writeTool({
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ENDPOINTS } from "../endpoints.js";
3
- import { assertFieldFamilyAllowed, choicesArg, fieldOptionsArg, nameI18nJson, propertyTypeArg, relationTargetOf, relativeCodeArg, SYSTEM_FIELD_CODES, subFieldArg, } from "../field-families.js";
3
+ import { assertFieldFamilyAllowed, assertSelectionChoicesAllowed, choicesArg, fieldOptionsArg, nameI18nJson, propertyTypeArg, relationTargetOf, relativeCodeArg, SYSTEM_FIELD_CODES, subFieldArg, } from "../field-families.js";
4
4
  import { fieldChangeWireBody, prepareFieldChange, sameFieldChange } from "../field-change.js";
5
5
  import { executeFormDraftWrite, executeFormPublishWrite } from "../form-support.js";
6
6
  import { mapBounded } from "../support.js";
@@ -1317,6 +1317,8 @@ export function registerModelLane(tools) {
1317
1317
  ];
1318
1318
  if (violations.length > 0)
1319
1319
  throw new ToolRefusalError({ status: "refused", blocker: "business-code-invalid", message: "code 未过出门前校验;按 validation 修正(长度/风格/保留字)", validation: violations });
1320
+ for (const entry of entries)
1321
+ assertSelectionChoicesAllowed(entry);
1320
1322
  for (const entry of entries)
1321
1323
  prepareFieldChange(fieldChangeInputOf(args.code, entry));
1322
1324
  },
@@ -1513,8 +1515,10 @@ function fieldEntriesOf(args) {
1513
1515
  if (Array.isArray(args.fields) && args.fields.length > 0) {
1514
1516
  if (args.fieldCode !== undefined || args.fieldName !== undefined || args.propertyType !== undefined)
1515
1517
  throw new ToolRefusalError({ status: "refused", blocker: "configure-model-fields-exclusive", message: "fields[] 与单字段参数(fieldCode/fieldName/propertyType)互斥;多字段用 fields[],单字段用旧三元组" });
1516
- for (const entry of args.fields)
1518
+ for (const entry of args.fields) {
1519
+ assertSelectionChoicesAllowed(entry);
1517
1520
  assertFieldFamilyAllowed({ ...entry });
1521
+ }
1518
1522
  return args.fields.map((entry) => ({ code: entry.code, name: entry.name, propertyType: entry.propertyType, ...withLegacy(entry) }));
1519
1523
  }
1520
1524
  if (typeof args.fieldCode !== "string" || typeof args.fieldName !== "string" || typeof args.propertyType !== "string")
@@ -1524,6 +1528,7 @@ function fieldEntriesOf(args) {
1524
1528
  message: "configure_model 需要 fields[](多字段)或 fieldCode+fieldName+propertyType(单字段)",
1525
1529
  });
1526
1530
  const legacy = { code: args.fieldCode, name: args.fieldName, propertyType: args.propertyType, ...withLegacy(args) };
1531
+ assertSelectionChoicesAllowed(legacy);
1527
1532
  assertFieldFamilyAllowed(legacy);
1528
1533
  return [legacy];
1529
1534
  }
@@ -531,10 +531,17 @@ export function registerOrganizationLane(tools) {
531
531
  remarks: z.string().optional(),
532
532
  corpId: z.string().min(1).optional().describe("组织 corpId,默认当前登录企业;传 \"other\" 表示非组织分组角色"),
533
533
  },
534
- execute: async (session, args) => {
534
+ preflight: (args) => {
535
535
  if (!args.roleGroupCode && !args.roleGroupId) {
536
- throw new ToolInputError({ message: "必须给 roleGroupCode(推荐)或 roleGroupId;先 org.role.list 取 groups 码表" });
536
+ throw new ToolRefusalError({
537
+ status: "refused",
538
+ blocker: "role-group-required",
539
+ nextCommand: "omc call org.role.list --args '{}'",
540
+ message: "必须给 roleGroupCode(推荐)或 roleGroupId;先 org.role.list 取 groups 码表",
541
+ });
537
542
  }
543
+ },
544
+ execute: async (session, args) => {
538
545
  const { groups } = await readRoleTree(session);
539
546
  if (groups.length === 0) {
540
547
  throw new ToolRefusalError({ status: "refused", blocker: "wire-shape", message: "org.role.list 未解析出任何角色组;先确认组织树可读,不要盲建角色" });
@@ -55,6 +55,7 @@ function dataListCompactProjection(result) {
55
55
  if (value.length > 0)
56
56
  compact.listArtifactHint =
57
57
  "items 已外置(truncatedKeys 含 items):完整行见 artifact(detailArtifactId / fetch)。同信封三分法:ids=本页全部 id;itemsPreview=ids 前 3 个的引用(仅 {id},不是第二份行);items=[] 表示正文在 artifact。";
58
+ compact.itemsExternalized = true;
58
59
  continue;
59
60
  }
60
61
  // 这两个键由本函数按 items 现算,handler 不再重复内联。
@@ -921,12 +922,12 @@ export function registerRuntimeDataLane(tools) {
921
922
  columns: z.array(z.string().min(1)).optional().describe("列投影别名(=displayFields)"),
922
923
  // r29b F9: data.load/save/update 都叫 fields;list 面原先只有 displayFields/columns。
923
924
  fields: z.array(z.string().min(1)).optional().describe("列投影别名(=displayFields;与 data.load/data.save/data.update 的 fields 同语义)"),
924
- orders: z.array(z.object({ field: z.string().min(1), dir: z.enum(["ASC", "DESC"]) })).optional(),
925
+ orders: z.array(z.object({ field: z.string().min(1), dir: z.enum(["ASC", "DESC"]) })).optional().describe("运行面 query/list 不支持服务端排序;排序请用 listview.configure 的 sorts(视图级)或在本地对 items 排序"),
925
926
  mobile: z.boolean().optional(),
926
927
  showTotal: z.boolean().default(true),
927
928
  page: z.number().int().min(0).default(0),
928
929
  size: z.number().int().min(1).max(200).default(10),
929
- all: z.boolean().optional().describe("true 时自动翻页聚合全部结果(内部按 size=200 逐页拉取,安全上限 1000 行;超过时回 truncated:true + missingCount,别当全集)——存量盘点/数据核对用,替代手动循环翻页。与 page 互斥(all 固定从第 0 页起)"),
930
+ all: z.boolean().optional().describe("true 时自动翻页聚合全部结果(size=200 逐页,上限 1000 行,超出 resultTruncated:true);与 page 互斥;默认 size=10,视图 pagingSetting.pageSize 不生效"),
930
931
  full: z.boolean().optional().describe("内联完整列表结果;默认大结果只返回计数与 detailFile"),
931
932
  // r31 (2026-10-31 real machine): agents pass `query: {field: value}` as a
932
933
  // filter — zod would STRIP the unknown key silently and the server
@@ -943,6 +944,14 @@ export function registerRuntimeDataLane(tools) {
943
944
  tool: "data.list",
944
945
  message: '没有 `query` 参数——过滤用 `filter: {"op":"eq","field":"<字段>","value":"<值>"}`(或 `filters` 数组=AND、`filterGroup` 嵌套)。r31 真机:`query:{...}` 被静默忽略、total 不变,极易误判为"无数据/已过滤"。',
945
946
  });
947
+ if (args.orders !== undefined)
948
+ throw new ToolInputError({
949
+ status: "error",
950
+ blocker: "invalid-input",
951
+ tool: "data.list",
952
+ message: "运行面 query/list 不支持服务端排序;排序请用 listview.configure 的 sorts(视图级)或在本地对 items 排序。",
953
+ nextCommand: `omc call listview.configure --env <env> --args '${JSON.stringify({ schemaCode: args.schemaCode, sorts: args.orders })}'`,
954
+ });
946
955
  let condition;
947
956
  let fromDsl = false;
948
957
  try {
@@ -1094,7 +1103,7 @@ export function registerRuntimeDataLane(tools) {
1094
1103
  const firstPage = extractBizObjectPage(first.data);
1095
1104
  const total = Number(firstPage.total ?? 0);
1096
1105
  if (total === 0 || firstPage.content.length === 0)
1097
- return { schemaCode: args.schemaCode, total: 0, page: "all", size: 0, pagesFetched: 0, items: [], ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
1106
+ return { schemaCode: args.schemaCode, total: 0, page: "all", size: 0, pagesFetched: 0, items: [], itemsCount: 0, itemsReturned: 0, resultTruncated: false, itemsExternalized: false, ...(choiceFilterFields.length > 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}) };
1098
1107
  const rows = [...firstPage.content];
1099
1108
  const target = Math.min(total, CAP);
1100
1109
  let pageIndex = 1;
@@ -1154,6 +1163,9 @@ export function registerRuntimeDataLane(tools) {
1154
1163
  // 形状常量:脚本按 result.items 取数(与内联态一致),itemsCount 与
1155
1164
  // total 的差就是被截断的条数——不再只靠 size 反推。
1156
1165
  itemsCount: collected.length,
1166
+ itemsReturned: collected.length,
1167
+ resultTruncated: truncatedByCap,
1168
+ itemsExternalized: false,
1157
1169
  // r29b F8 的 ids/itemsPreview 由 compact(外置时)现算,不在这里内联——
1158
1170
  // 内联会让小结果越过 2048B 外置阈值,反而把 items 挤成 []。
1159
1171
  ...(truncatedByCap
@@ -1190,9 +1202,13 @@ export function registerRuntimeDataLane(tools) {
1190
1202
  if (probeError instanceof WireError && probeError.kind === "business" && probeError.errcode === 300004) {
1191
1203
  return {
1192
1204
  status: "refused",
1193
- blocker: "wire-business",
1205
+ blocker: "schema-not-found",
1194
1206
  errcode: 300004,
1195
- message: `数据模型不存在(${args.schemaCode})——运行面读取前模型必须已创建并发布字段;先 model.create / configure_model(存在性判据用 model.inspect 的 modelPresent)`,
1207
+ schemaCode: args.schemaCode,
1208
+ exists: false,
1209
+ total: 0,
1210
+ message: `数据模型不存在(${args.schemaCode})——运行面读取前模型必须已创建并发布字段。`,
1211
+ nextCommand: "omc call model.list --args '{}'",
1196
1212
  };
1197
1213
  }
1198
1214
  }
@@ -1280,6 +1296,10 @@ export function registerRuntimeDataLane(tools) {
1280
1296
  })(),
1281
1297
  ...(choiceFilterFields.length > 0 && Number(total ?? 0) === 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}),
1282
1298
  totalPages,
1299
+ itemsCount: items.length,
1300
+ itemsReturned: items.length,
1301
+ resultTruncated: false,
1302
+ itemsExternalized: false,
1283
1303
  page: page.page ?? requestedPage,
1284
1304
  size: page.size ?? args.size ?? 20,
1285
1305
  ...(pageOutOfRange ? { pageOutOfRange: true, emptyPage: true } : {}),
@@ -78,6 +78,7 @@ export const WORKFLOW_WIRE = Object.freeze({
78
78
  workItemFilter: "WorkItemSearchVO.filterWorkflowInstanceId is a NOT-equal exclusion (BizWorkitemServiceImpl.toSearchWorkItemsCriterion:400-402), so exact instance filtering is client-side",
79
79
  bizObjectResolution: "GET /api/api/runtime/workflow/getBizObjectByWfInstanceId resolves bizObjectId/schemaCode; a failed lookup leaves the identity null and never drops the todo",
80
80
  mutuallyExclusiveBinding: "同一处理器绑定多条规则时它们**全部执行、无互斥**:审批通过/驳回这类互斥结果不要用两条各自 FIXED 赋值都绑 end(bench22 病灶)——通过绑 end(endEventHandler)或规则 Available,驳回绑 cancel(cancelEventHandler)或规则 Cancel,或用一条多分支图(生产 case_info_update 只在 endEventHandler 绑一条 updateData 多分支图);workflow.bindRule 回读在 bizActions.length>1 时给 advisory",
81
+ participantRoleValidation: "写前参与人校验:PARTICIPANT 的 FindUserByRole(<org>,\"<roleCode>\",\"<order>\") 会与 org.role.list 的真实角色 code 比对;缺失即拒绝 workflow-participant-role-not-found,并给 activityCode/activityName/roleCode、最多 5 个 candidates 与 nextCommand。org.role.list 读取失败时 fail-open,不阻断写入。{Originator} 是发起人关键字,不按角色校验;所有 PARTICIPANT 都是 {Originator} 时给 approval-by-originator warning。",
81
82
  userSearch: "POST /api/api/organization/user/search,@RequestBody {wd (关键字,必填), page, size, filterType?} → PageVO<UserVO>{totalElements,totalPages,content[{id,name,username,userId,departmentId,corpId,status}]}(UserController.search:337-341;缺 wd 报 10005 关键字不能为空)。用于把姓名解析成真实用户,构造参与者对象 type 3=用户(type 2=角色)",
82
83
  });
83
84
  /** Workflow template object (workflow/update|publish raw body). */
@@ -117,6 +118,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
117
118
  bizObjectResolution: WORKFLOW_WIRE.bizObjectResolution,
118
119
  mutuallyExclusiveBinding: WORKFLOW_WIRE.mutuallyExclusiveBinding,
119
120
  userSearch: WORKFLOW_WIRE.userSearch,
121
+ participantRoleValidation: WORKFLOW_WIRE.participantRoleValidation,
120
122
  }),
121
123
  wireBindings: Object.freeze({
122
124
  templateShape: Object.freeze([behaviorBinding("test/ticket-112-workflow-template-merge.test.ts#workflow.update merges onto the draft", assertWorkflowContract)]),
@@ -135,6 +137,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
135
137
  bizObjectResolution: Object.freeze([emitterBinding("workflow-support.resolveBizObjectIdByInstance", assertWorkflowContract)]),
136
138
  mutuallyExclusiveBinding: Object.freeze([behaviorBinding("test/issue-14-workflow-bind-rule.test.ts#workflow.bindRule merges endEventHandler", assertWorkflowContract)]),
137
139
  userSearch: Object.freeze([behaviorBinding("test/round4-org-user-search.test.ts#org.user.search resolves a user by name into participant fields", assertWorkflowContract)]),
140
+ participantRoleValidation: Object.freeze([behaviorBinding("test/workflow-participant-role.test.ts#workflow.update refuses an unknown FindUserByRole role before the wire", assertWorkflowContract)]),
138
141
  }),
139
142
  evidence: Object.freeze([
140
143
  ".scratch/omc-approval-workflow/acceptance/s29-probe-20260910012330184.md",
@@ -160,7 +163,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
160
163
  }),
161
164
  "workflow.update": Object.freeze({
162
165
  scope: WORKFLOW_SCOPE,
163
- description: "写入工作流模板草稿(`/api/api/workflow/update`,raw template JSON body)。cli 以当前草稿为基线做 merge,未列出的既有节点保留:**模板缺 START 或 END 时自动按增量 merge(部分模板),给全 START..END 才整模板替换**;也可显式 `merge:true/false`。**连线三种写法选一**:`routes`(recipe 写法)/ `edges`(同义)/ `rules`(平台 DTO 字段名,`get_draft` 读回原样)——同传两种会被拒(`workflow-routes-ambiguous`);merge 是并集(草稿边 + 你的边),**绝不删边**,若结果会比草稿少边则拒绝(`workflow-merge-would-drop-routes`,回 routesBefore/After + 缺失连线),dry-run 也会给出 `routesBefore/routesAfter/routesAdded/routesDropped`。**节点形状**:新增节点必须给 `type`/`activityType`(可选 START/PARTICIPANT/END…),`activityCode` 命中草稿既有节点时可省(类型/名称继承草稿);**字段权限只发改动行**——`propertyPermissions` 是载荷体积的主要来源(整模板单次常到 1.0–1.4MB,超过 `--args-file` 上限 `CLI_ARGS_MAX_BYTES`=1MB 会被拒),权限编辑只需 `activities:[{activityCode, propertyPermissions:[{propertyCode,…}]}]`。**节点字段权限也走这里**:不必读 `get_draft --full`——直接发 `activities:[{activityCode, propertyPermissions:[{propertyCode, visible, editable, required, summary, subPropertyPermissions}]}]`(**type 可省**:activityCode 命中草稿节点时类型/名称继承草稿,不会铸 `_2` 新码;**改名=同 code 原地生效**——工具把新名写入 `name_i18n`(zh/zh_TW/en),activityName 平台侧保持首建值,读回以 `renamedInPlace.displayName` 为准;只有**类型变更**才铸新码并弃旧码),工具按 propertyCode 合并到草稿对应节点(未列的字段权限保留;`merge:true` 也可同用)——没有单独的权限工具,别把「找不到入口」当成不支持(dogfood 实测该误判会假阻塞)。`summary` **无需配置**:工具合并时自动补 `summary:true`(平台怪癖——缺省进不了运行时 `activitySummaries`、`form/load` 读不到你的 `editable:false`;显式传 `false` 才保留);子表字段用 `subPropertyPermissions`(同样自动补)。**子表属性不可设 `required:true`**(接口形状接受但设计器节点字段配置不支持,存入后无法渲染——工具直接拒 `workflow-child-table-not-required`);子表整行只配 visible/editable,字段级必填配到子表内部字段所在节点。**新增节点用语义 code**(如 `approval_finance`),不要模仿草稿的 `ActivityN` 再加 `_1/_2` 后缀。**设计器默认自动补齐**(调用方显式传值优先):填写节点({Originator})自动生成子表操作按钮 `buttonPermission.childTableBtns`(每个子表 ADD/DELETE/IMPORT/EXPORT 启用、BATCH_UPDATE 禁用——API 建的流程缺这份模板,设计器里子表按钮会全部不勾);`permittedAction` 归一为设计器默认(填写节点 showReject:false 不能驳回自己,审批节点 rejectToStart:true 驳回回发起节点)。用 `npm run test:live-permission`(非管理员参与者)验收,管理员会被后端强制 `e:true` 掩盖。",
166
+ description: "写入工作流模板草稿(`/api/api/workflow/update`,raw template JSON body)。cli 以当前草稿为基线做 merge,未列出的既有节点保留:**模板缺 START 或 END 时自动按增量 merge(部分模板),给全 START..END 才整模板替换**;也可显式 `merge:true/false`。**连线三种写法选一**:`routes`(recipe 写法)/ `edges`(同义)/ `rules`(平台 DTO 字段名,`get_draft` 读回原样)——同传两种会被拒(`workflow-routes-ambiguous`);merge 是并集(草稿边 + 你的边),**绝不删边**,若结果会比草稿少边则拒绝(`workflow-merge-would-drop-routes`,回 routesBefore/After + 缺失连线),dry-run 也会给出 `routesBefore/routesAfter/routesAdded/routesDropped`。**节点形状**:新增节点必须给 `type`/`activityType`(可选 START/PARTICIPANT/END…),`activityCode` 命中草稿既有节点时可省(类型/名称继承草稿);**字段权限只发改动行**——`propertyPermissions` 是载荷体积的主要来源(整模板单次常到 1.0–1.4MB,超过 `--args-file` 上限 `CLI_ARGS_MAX_BYTES`=1MB 会被拒)。**节点字段权限也走这里**:不必读 `get_draft --full`——直接发 `activities:[{activityCode, propertyPermissions:[{propertyCode, visible, editable, required, summary, subPropertyPermissions}]}]`(**type 可省**:activityCode 命中草稿节点时类型/名称继承草稿,不会铸 `_2` 新码;**改名=同 code 原地生效**——工具把新名写入 `name_i18n`(zh/zh_TW/en),activityName 平台侧保持首建值,读回以 `renamedInPlace.displayName` 为准;只有**类型变更**才铸新码并弃旧码),工具按 propertyCode 合并到草稿对应节点(未列的字段权限保留;`merge:true` 也可同用)——没有单独的权限工具,别把「找不到入口」当成不支持(dogfood 实测该误判会假阻塞)。`summary` **无需配置**:工具合并时自动补 `summary:true`(平台怪癖——缺省进不了运行时 `activitySummaries`、`form/load` 读不到你的 `editable:false`;显式传 `false` 才保留);子表字段用 `subPropertyPermissions`(同样自动补)。**参与人写前校验**:`FindUserByRole(…,\"<roleCode>\",…)` 与 org.role.list 比对,未知角色拒 `workflow-participant-role-not-found`(带候选);全 `{Originator}` 给 `approval-by-originator` warning。**子表属性不可设 `required:true`**(接口形状接受但设计器节点字段配置不支持,存入后无法渲染——工具直接拒 `workflow-child-table-not-required`);子表整行只配 visible/editable,字段级必填配到子表内部字段所在节点。**新增节点用语义 code**(如 `approval_finance`),不要模仿草稿的 `ActivityN` 再加 `_1/_2` 后缀。**设计器默认自动补齐**(调用方显式传值优先):填写节点({Originator})自动生成子表操作按钮 `buttonPermission.childTableBtns`(每个子表 ADD/DELETE/IMPORT/EXPORT 启用、BATCH_UPDATE 禁用——API 建的流程缺这份模板,设计器里子表按钮会全部不勾);`permittedAction` 归一为设计器默认(填写节点 showReject:false 不能驳回自己,审批节点 rejectToStart:true 驳回回发起节点)。",
164
167
  }),
165
168
  "workflow.publish": Object.freeze({
166
169
  scope: WORKFLOW_SCOPE,
@@ -241,6 +244,7 @@ export function assertWorkflowContract() {
241
244
  assertDescriptionCarries(WORKFLOW_LANE_CONTRACT, "org.role.list", ["organization/role/list", "type", "Originator"]);
242
245
  assertDescriptionCarries(WORKFLOW_LANE_CONTRACT, "org.department.list", ["organization/department/tree", "parentId"]);
243
246
  assertDescriptionCarries(WORKFLOW_LANE_CONTRACT, "org.user.search", ["organization/user/search", "wd", "type 3"]);
247
+ assertDescriptionCarries(WORKFLOW_LANE_CONTRACT, "workflow.update", ["FindUserByRole", "workflow-participant-role-not-found", "approval-by-originator"]);
244
248
  for (const [key, field] of Object.entries(WORKFLOW_EVENT_HANDLER_FIELDS)) {
245
249
  if (!WORKFLOW_WIRE.bindRuleChain.includes(key))
246
250
  throw new Error(`workflow contract bind-rule chain must mention the ${key} handler key`);
@@ -767,6 +767,18 @@ function participantReferenceWarnings(template) {
767
767
  }
768
768
  return warnings;
769
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
+ }
770
782
  /**
771
783
  * Role participants are filtered by `ouScope ∩ originator's department chain`.
772
784
  * A role whose members all have an empty scope therefore resolves to NOBODY —
@@ -782,12 +794,15 @@ async function assertRoleParticipantScopes(session, body) {
782
794
  ? Object.values(body.activities)
783
795
  : [];
784
796
  const roles = [];
797
+ const functionRoles = [];
785
798
  for (const activity of rows) {
786
799
  if (String(activity.activityType ?? "") !== "PARTICIPANT")
787
800
  continue;
788
801
  const raw = activity.participant;
789
802
  if (typeof raw !== "string" || !raw.trim().startsWith("["))
790
803
  continue;
804
+ const activityCode = String(activity.activityCode ?? "");
805
+ const activityName = String(activity.activityName ?? activity.name ?? activityCode);
791
806
  for (const segment of raw.split("+")) {
792
807
  let parsed;
793
808
  try {
@@ -806,7 +821,86 @@ async function assertRoleParticipantScopes(session, body) {
806
821
  continue;
807
822
  const roleId = String(record.id ?? "");
808
823
  if (roleId)
809
- 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
+ });
810
904
  }
811
905
  }
812
906
  }
@@ -2359,12 +2453,14 @@ export function registerWorkflowLane(tools) {
2359
2453
  return { schemaCode: args.schemaCode, exists: false, note: "该模型没有流程可校验" };
2360
2454
  const template = (args.template ?? {});
2361
2455
  const body = await mergedTemplateBody(session, args.schemaCode ?? "", workflowCode, template, activitiesLackBoundaries(template));
2456
+ await assertRoleParticipantScopes(session, body);
2362
2457
  const result = await wireCall(session, { path: ENDPOINTS.workflowValidate, method: "POST", body });
2363
2458
  const messages = Array.isArray(result.data) ? result.data : [];
2364
2459
  const errors = messages.filter((message) => message.errorLevel === "ERROR");
2365
2460
  const warnings = messages.filter((message) => message.errorLevel !== "ERROR");
2366
2461
  // r31c F23: local reachability / else-coverage — warning only, never flips valid.
2367
2462
  const reachability = workflowReachabilityWarnings(body);
2463
+ const originatorWarnings = approvalByOriginatorWarnings(body);
2368
2464
  const allWarnings = [
2369
2465
  ...warnings,
2370
2466
  ...reachability.map((entry) => ({
@@ -2375,6 +2471,7 @@ export function registerWorkflowLane(tools) {
2375
2471
  name: entry.name,
2376
2472
  problem: entry.problem,
2377
2473
  })),
2474
+ ...originatorWarnings.map((entry) => ({ errorLevel: "WARNING", errCode: entry.code, errContent: entry.message, activityCodes: entry.activityCodes })),
2378
2475
  ];
2379
2476
  return {
2380
2477
  workflowCode,
@@ -2384,6 +2481,7 @@ export function registerWorkflowLane(tools) {
2384
2481
  errors,
2385
2482
  warnings: allWarnings,
2386
2483
  ...(reachability.length > 0 ? { reachabilityWarnings: reachability } : {}),
2484
+ ...(originatorWarnings.length > 0 ? { approvalWarnings: originatorWarnings } : {}),
2387
2485
  };
2388
2486
  }, { target: `workflow.validate:${args.workflowCode ?? args.schemaCode}` }),
2389
2487
  }));
@@ -2486,6 +2584,7 @@ export function registerWorkflowLane(tools) {
2486
2584
  // Evaluate on `body` (post-backfill): the readback may drop the handler
2487
2585
  // keys, and the backfilled permittedAction is exactly what will run.
2488
2586
  const rejectWarnings = rejectToStartCancelWarnings(body);
2587
+ const originatorWarnings = approvalByOriginatorWarnings(body);
2489
2588
  const renamedActivities = renamedActivityPairs(template, data);
2490
2589
  const renamedInPlace = inPlaceRenames(data);
2491
2590
  // Caller sent no routes at all: the draft's edges (branches included) are
@@ -2503,7 +2602,7 @@ export function registerWorkflowLane(tools) {
2503
2602
  const activityNames = Object.fromEntries(rawActivityRows
2504
2603
  .map((row) => [String(row.activityCode ?? ""), effectiveActivityName(row)])
2505
2604
  .filter(([code]) => code !== ""));
2506
- 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) } };
2507
2606
  },
2508
2607
  preflight: (args) => {
2509
2608
  // Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
@@ -2592,6 +2691,7 @@ export function registerWorkflowLane(tools) {
2592
2691
  const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
2593
2692
  await backfillDesignerDefaults(session, args.schemaCode, body, template);
2594
2693
  assertFirstActivityIsOriginator(template, "workflow.publish", body);
2694
+ await assertRoleParticipantScopes(session, body);
2595
2695
  const unregistered = unregisteredParticipantFunctions(body);
2596
2696
  if (unregistered.length > 0)
2597
2697
  throw new ToolRefusalError({
@@ -2629,7 +2729,8 @@ export function registerWorkflowLane(tools) {
2629
2729
  validateWorkflowRender(data, "workflow.publish readback");
2630
2730
  const participantWarnings = participantReferenceWarnings(data);
2631
2731
  const rejectWarnings = rejectToStartCancelWarnings(data);
2632
- 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) } };
2633
2734
  },
2634
2735
  preflight: (args) => {
2635
2736
  assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");
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}`);
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
@@ -109,7 +109,7 @@ const SYNTHETIC_SKILLS = Object.freeze({
109
109
  "# 批量写",
110
110
  "",
111
111
  "通用批量入口是 `omc call <tool> --args-list '<json数组>'`,逐条执行且默认不中断。",
112
- "先做零写入形状校验:`omc call data.save --args-list '<file|stdin>' --preflight`;回执 `shapeOnly:true`,只校验 JSON 形状与 schema 必填键,不做业务语义校验;业务校验要真写一次才知道。",
112
+ "先做零写入形状校验:`omc call data.save --args-list '<json数组>' --preflight`;回执 `shapeOnly:true`,只校验 JSON 形状与 schema 必填键,不做业务语义校验;业务校验要真写一次才知道。大数组请用 shell 变量传入(`--args-list` 暂不读取文件或 stdin)。",
113
113
  "支持 `data.save`、`data.delete`、`field.create` 等同一工具逐条批量调用;没有专用 `data.import`,导入数据就是逐条 `data.save`。",
114
114
  "失败项回 `row`(1 基)、`index`(0 基)和尽力推断的字段/原因;成功项回输入中的 `code` 等业务标识。",
115
115
  "",
@@ -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.16",
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.16",
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.16"
76
+ "@stalfh233/omc-fake-platform": "0.6.17"
77
77
  }
78
78
  }