@stalfh233/omc-cli 0.6.12 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/coverage/m1-coverage-manifest-v1.json +2 -0
  2. package/dist/approval-flow.js +84 -3
  3. package/dist/batch-call.js +32 -2
  4. package/dist/bench/call-face-eval.js +2 -2
  5. package/dist/call.js +35 -2
  6. package/dist/capability-overview.js +21 -7
  7. package/dist/cli-contract.js +8 -2
  8. package/dist/cli.js +313 -72
  9. package/dist/describe-cache.js +8 -4
  10. package/dist/discovery.js +4 -0
  11. package/dist/environment-policy.js +20 -0
  12. package/dist/field-families.js +62 -4
  13. package/dist/form-support.js +293 -6
  14. package/dist/goal-routes.js +137 -22
  15. package/dist/identity.js +63 -11
  16. package/dist/lanes/app-contract.js +4 -4
  17. package/dist/lanes/app.js +20 -2
  18. package/dist/lanes/dictionary-contract.js +16 -5
  19. package/dist/lanes/dictionary-coverage.js +1 -0
  20. package/dist/lanes/dictionary.js +200 -8
  21. package/dist/lanes/environment-contract.js +18 -6
  22. package/dist/lanes/environment.js +129 -16
  23. package/dist/lanes/field-contract.js +3 -2
  24. package/dist/lanes/field.js +785 -63
  25. package/dist/lanes/form.js +52 -6
  26. package/dist/lanes/list-view-contract.js +3 -3
  27. package/dist/lanes/list-view.js +143 -12
  28. package/dist/lanes/meta.js +36 -10
  29. package/dist/lanes/model-contract.js +7 -6
  30. package/dist/lanes/model.js +154 -10
  31. package/dist/lanes/organization.js +67 -7
  32. package/dist/lanes/runtime-data-contract.js +4 -2
  33. package/dist/lanes/runtime-data.js +395 -41
  34. package/dist/lanes/workflow-contract.js +2 -2
  35. package/dist/lanes/workflow.js +381 -11
  36. package/dist/model-scaffold.js +11 -3
  37. package/dist/path-projection.js +181 -31
  38. package/dist/read-shapes.js +31 -11
  39. package/dist/recipes/b2-registration.js +1 -1
  40. package/dist/recipes/field.js +16 -3
  41. package/dist/recipes/form.js +4 -3
  42. package/dist/recipes/runtime-data.js +58 -8
  43. package/dist/recipes/workflow.js +86 -1
  44. package/dist/refusal-guidance.js +18 -6
  45. package/dist/rollback-spec.js +41 -2
  46. package/dist/rollback.js +49 -11
  47. package/dist/script.js +72 -12
  48. package/dist/skills.js +91 -3
  49. package/dist/tools.js +113 -1
  50. package/dist/wire.js +13 -1
  51. package/dist/write-lock.js +92 -4
  52. package/dist/write.js +129 -16
  53. package/package.json +3 -3
  54. package/skills/omc-business-configuration.md +1 -1
  55. package/skills/omc-capabilities.md +5 -4
  56. package/skills/omc-playbook.md +41 -0
  57. package/skills/omc-refusals.md +58 -6
  58. package/skills/omc-tool-selection.md +2 -0
  59. package/skills/omc-write-hazards.md +3 -2
@@ -48,6 +48,7 @@
48
48
  "dictionary.save",
49
49
  "dictionary.status",
50
50
  "dictionary.usage",
51
+ "dictionary.references",
51
52
  "dictionary.classes",
52
53
  "dictionary.class.save",
53
54
  "dictionary.class.delete"
@@ -62,6 +63,7 @@
62
63
  "dictionary save",
63
64
  "dictionary status",
64
65
  "dictionary record usage",
66
+ "dictionary refs",
65
67
  "dictionary classes",
66
68
  "dictionary class save",
67
69
  "dictionary class delete"
@@ -20,15 +20,64 @@ import { runToolCall } from "./call.js";
20
20
  import { envelope, requestId, classifyResult, CLI_EXIT_CODES } from "./cli-contract.js";
21
21
  import { writeEnvelope } from "./cli-output.js";
22
22
  import { argvValue } from "./args.js";
23
+ /**
24
+ * r31c F20: participant object arrays only accept `{code,corpId,id,name,type}`.
25
+ * Intuitive keys (`participantType`/`roleId`/`participantName`) are silently
26
+ * dropped by the platform → readback `[{}]`, validate still valid, publish only
27
+ * warns. Refuse here so the composer never emits a broken shape.
28
+ */
29
+ export function assertParticipantShape(participant, at) {
30
+ if (participant === undefined || participant === null)
31
+ return;
32
+ if (typeof participant === "string") {
33
+ const trimmed = participant.trim();
34
+ if (trimmed === "{Originator}")
35
+ return;
36
+ if (/^FindUserByRole\s*\(/.test(trimmed))
37
+ return;
38
+ if (trimmed.startsWith("[")) {
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(trimmed);
42
+ }
43
+ catch {
44
+ throw new Error(`${at}: participant JSON 解析失败;对象数组形如 [{"code":"<角色code>","corpId":"other","id":"<roleId>","name":"<名>","type":2}]`);
45
+ }
46
+ assertParticipantShape(parsed, at);
47
+ return;
48
+ }
49
+ throw new Error(`${at}: participant 自由文本「${trimmed}」不可解析(不生成待办)。用 {Originator} / FindUserByRole(...) / 对象数组 {code,corpId,id,name,type}`);
50
+ }
51
+ if (!Array.isArray(participant)) {
52
+ throw new Error(`${at}: participant 必须是字符串或对象数组`);
53
+ }
54
+ for (const [index, entry] of participant.entries()) {
55
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
56
+ throw new Error(`${at}[${index}]: 参与者条目必须是对象`);
57
+ }
58
+ const record = entry;
59
+ const badKeys = ["participantType", "roleId", "participantName", "userId"].filter((key) => key in record);
60
+ if (badKeys.length > 0) {
61
+ throw new Error(`${at}[${index}]: 不认 ${badKeys.join("/")}——平台会静默丢掉,读回变成 [{}]。正确键:code/corpId/id/name/type(type 2=角色 3=用户)`);
62
+ }
63
+ if (record.code === undefined || record.type === undefined) {
64
+ throw new Error(`${at}[${index}]: 参与者对象缺 code/type(必填)。正确形状:{"code":"<角色或用户code>","corpId":"other","id":"<id>","name":"<名>","type":2}`);
65
+ }
66
+ }
67
+ }
23
68
  /** 解析步骤 DSL 为 {name, participant}(导出供测试)。 */
24
69
  export function parseStep(raw) {
25
70
  const match = /^(.+?)@(role|user):([A-Za-z0-9_.:-]+)$/.exec(raw.trim());
26
71
  if (!match)
27
72
  throw new Error(`--steps "${raw}" 解析失败:形如 名称@role:<角色code> 或 名称@user:<用户id>(自由文本角色名能发布但解析不到人、不生成待办)`);
28
73
  const [, name, kind, code] = match;
74
+ // Role steps stay on FindUserByRole (department-scoped). User steps emit the
75
+ // canonical object array — never participantType/roleId.
29
76
  if (kind === "role")
30
77
  return { name: name.trim(), participant: `FindUserByRole({Originator.OU},"${code}","random")` };
31
- return { name: name.trim(), participant: JSON.stringify([{ code, corpId: "other", id: code, name: name.trim(), type: 3 }]) };
78
+ const participant = JSON.stringify([{ code, corpId: "other", id: code, name: name.trim(), type: 3 }]);
79
+ assertParticipantShape(participant, `--steps ${name}`);
80
+ return { name: name.trim(), participant };
32
81
  }
33
82
  /** 编译完整模板(recipe 验证形状)。 */
34
83
  export function composeApprovalTemplate(options) {
@@ -44,6 +93,7 @@ export function composeApprovalTemplate(options) {
44
93
  }
45
94
  for (const raw of options.steps) {
46
95
  const { name, participant } = parseStep(raw);
96
+ assertParticipantShape(participant, `steps[${name}]`);
47
97
  activities.push({
48
98
  activityCode: `Activity${n}`, activityType: "PARTICIPANT", activityName: name, i18nMessage: name,
49
99
  participant,
@@ -74,13 +124,26 @@ export async function cmdApprovalFlow(args) {
74
124
  const apply = args.includes("--apply");
75
125
  const originatorNode = !args.includes("--no-originator-node");
76
126
  const rejectToStart = !args.includes("--no-reject-to-start");
77
- const usage = '用法:omc approval-flow --schema <模型> --steps "节点名@role:<角色code>" [--steps ...多级] [--no-originator-node] [--no-reject-to-start] [--apply]\n步骤也可用 名称@user:<用户id> 绑定具体审批人。默认 dry-run(编译模板预览);--apply 建/改流程+字段权限+发布。';
127
+ // r31c F5:本拼装器只做线性审批。条件分支必须退回 workflow.update;行级数据
128
+ // 可见范围当前工具面不支持——别把「申请人只看自己」误当成 permissions。
129
+ const usage = [
130
+ '用法:omc approval-flow --schema <模型> --steps "节点名@role:<角色code>" [--steps ...多级] [--no-originator-node] [--no-reject-to-start] [--apply]',
131
+ "步骤也可用 名称@user:<用户id> 绑定具体审批人。默认 dry-run(编译模板预览);--apply 建/改流程+字段权限+发布。",
132
+ "⚠️ 本工具只做线性流程(START→填写→N 级审批→END),没有 --condition。",
133
+ "条件分支请用:omc describe workflow.update --example workflow-conditional-branch",
134
+ '最小条件流示例:routes 上加 ruleConditionGroup:[[{currentDataItem:"amount",bizPropertyType:2,conditionType:"GT",value:"50000"}]],兜底线加 utilizeElse:true。',
135
+ "⚠️ workflow.permissions / org.role.setUserScope 都不是行级数据权限;「申请人只能看自己的单据」当前工具面不支持。",
136
+ ].join("\n");
78
137
  const invalid = (message) => {
79
138
  writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: `${message}\n${usage}`, retryable: false }));
80
139
  return CLI_EXIT_CODES.invalidInput;
81
140
  };
82
141
  if (!schema)
83
142
  return invalid("--schema 必填");
143
+ // Explicit refuse of a flag we do not implement, so callers do not assume silent ignore.
144
+ if (args.includes("--condition") || args.includes("--branch") || args.some((arg) => arg.startsWith("--condition=") || arg.startsWith("--branch="))) {
145
+ return invalid("本工具不支持 --condition/--branch(只做线性审批)。条件分流请:omc describe workflow.update --example workflow-conditional-branch");
146
+ }
84
147
  let compiled;
85
148
  try {
86
149
  compiled = composeApprovalTemplate({ schema, steps, originatorNode, rejectToStart, environment, apply });
@@ -90,8 +153,26 @@ export async function cmdApprovalFlow(args) {
90
153
  }
91
154
  // F1(r27):边界前置——成功回执就标出跨账号审批在单会话 CLI 内不可用。
92
155
  const crossAccountApproval = "unsupported: single-session CLI";
156
+ const linearOnlyNote = "本工具只做线性审批;条件分支用 omc describe workflow.update --example workflow-conditional-branch。" +
157
+ "workflow.permissions 只设谁能发起,不是行级可见范围(行级数据权限当前工具面不支持)。";
93
158
  if (!apply) {
94
- writeEnvelope(envelope("ok", { schemaCode: schema, ...compiled, dryRun: true, crossAccountApproval, note: "dry-run 仅编译预览;--apply 执行 workflow.create(无流程时)→update(模板+字段权限)→publish。边界:非发起人身份的审批无法在单会话 CLI 内推进(单 token);若要验跨账号审批,用第二账号另建环境,或把参与人设为 {Originator}" }, request));
159
+ writeEnvelope(envelope("ok", {
160
+ schemaCode: schema,
161
+ ...compiled,
162
+ dryRun: true,
163
+ crossAccountApproval,
164
+ linearOnly: true,
165
+ rowLevelDataPermission: "unsupported",
166
+ note: `dry-run 仅编译预览;--apply 执行 workflow.create(无流程时)→update(模板+字段权限)→publish。边界:非发起人身份的审批无法在单会话 CLI 内推进(单 token);若要验跨账号审批,用第二账号另建环境,或把参与人设为 {Originator}。${linearOnlyNote}`,
167
+ conditionExample: {
168
+ tool: "workflow.update",
169
+ example: "workflow-conditional-branch",
170
+ routesSnippet: [
171
+ { preNode: "A2", postNode: "A3", ruleConditionGroup: [[{ currentDataItem: "amount", bizPropertyType: 2, conditionType: "GT", value: "50000" }]] },
172
+ { preNode: "A2", postNode: "A4", utilizeElse: true },
173
+ ],
174
+ },
175
+ }, request));
95
176
  return 0;
96
177
  }
97
178
  // 1) 确保流程存在(一个模型只能有一个:已存在则直接 update)
@@ -76,6 +76,29 @@ export async function runBatchCall(options) {
76
76
  }));
77
77
  const executed = entries.length;
78
78
  const skipped = options.argsList.length - executed;
79
+ // 2026-11-05(r30c F3/F10/F13):全成功时旧回执只有 4 个计数,调用方拿不到
80
+ // **每条的 id**——想确认「这 5 条到底进没进库」必须再发一次 data.list,而批量
81
+ // 存在的理由正是省掉那一次往返。反过来把 8 份完整信封塞回来又会重建本命令要
82
+ // 消除的那堵墙。折中:**每条一行摘要**(几十字节),超过阈值才截断。
83
+ const ENTRIES_INLINE_LIMIT = 20;
84
+ const entriesSummary = entries.map((entry) => {
85
+ const readback = entry.result.readback;
86
+ const performed = entry.result.performed;
87
+ const bizObjectId = typeof performed?.bizObjectId === "string"
88
+ ? String(performed.bizObjectId)
89
+ : typeof readback?.authoritative?.id === "string"
90
+ ? String(readback.authoritative.id)
91
+ : undefined;
92
+ const skippedMarker = performed?.skipped;
93
+ return {
94
+ index: entry.index,
95
+ ok: entry.status === "ok",
96
+ ...(bizObjectId !== undefined ? { bizObjectId } : {}),
97
+ ...(entry.status === "ok" ? {} : { status: String(entry.result.status ?? "failed"), blocker: entry.result.blocker ?? null }),
98
+ ...(skippedMarker !== undefined ? { skipped: String(skippedMarker) } : {}),
99
+ };
100
+ });
101
+ const entriesInline = entriesSummary.slice(0, ENTRIES_INLINE_LIMIT);
79
102
  return {
80
103
  exitCode: failed > 0 ? 1 : 0,
81
104
  result: {
@@ -86,8 +109,15 @@ export async function runBatchCall(options) {
86
109
  succeeded,
87
110
  failed,
88
111
  ...(skipped > 0 ? { notExecuted: skipped } : {}),
89
- // Only failures carry per-entry detail: echoing 8 full success envelopes
90
- // would rebuild exactly the wall of text this command exists to remove.
112
+ // 每条一行摘要(index/ok/bizObjectId)——不是完整信封,但足以回答
113
+ // 「第 3 条建成了吗、它的 id 是什么」。失败项另有 failures 带完整原因。
114
+ entries: entriesInline,
115
+ ...(entries.length > entriesInline.length
116
+ ? { entriesOmitted: entries.length - entriesInline.length, entriesHint: `条目超过 ${ENTRIES_INLINE_LIMIT} 条,只内联前 ${ENTRIES_INLINE_LIMIT} 条摘要;完整结果走 detailArtifactId / omc artifact get` }
117
+ : {}),
118
+ // Only failures carry full per-entry detail: echoing 8 full success
119
+ // envelopes would rebuild exactly the wall of text this command exists
120
+ // to remove.
91
121
  ...(failures.length > 0 ? { failures } : {}),
92
122
  ...(skipped > 0 ? { notExecutedNote: "未执行的条目通常是因为解析阶段就失败(如缺少必填参数);先修 failures 里的第一条" } : {}),
93
123
  },
@@ -163,8 +163,8 @@ export const CALL_FACE_TASKS = [
163
163
  ok(replay.code === 0 && replay.envelope.result?.status === "ok" && replay.envelope.result?.replay === "already-completed", `replay=${JSON.stringify(replay.envelope).slice(0, 260)}`);
164
164
  ok(server.fake.bodiesFor("/api/api/app/bizmodels/create").length === 1, "replay must not send a second create wire");
165
165
  const diverged = await agent.run(["call", "model.create", "--env", "eval", "--args", JSON.stringify({ name: "幂等模型2", code: "eval_idem2" }), "--request-id", "eval-idem-1"]);
166
- ok(diverged.code === 0 && diverged.envelope.result?.status === "ok", "same requestId with diverged args must execute as a new request");
167
- ok(server.fake.bodiesFor("/api/api/app/bizmodels/create").length === 2, "diverged args must not replay the first request");
166
+ ok(diverged.code === 12 && diverged.envelope.result?.blocker === "request-id-args-mismatch", `diverged args must refuse mismatch, got ${JSON.stringify(diverged.envelope).slice(0, 260)}`);
167
+ ok(server.fake.bodiesFor("/api/api/app/bizmodels/create").length === 1, "diverged args must not write a second create");
168
168
  },
169
169
  },
170
170
  {
package/dist/call.js CHANGED
@@ -9,6 +9,7 @@ import { parseToolInput, ToolInputError } from "./tool-types.js";
9
9
  import { absencePointer } from "./capability-absences.js";
10
10
  import { closestMatches } from "./closest-match.js";
11
11
  import { attachRefusalRecovery } from "./refusal-guidance.js";
12
+ import { lockOwner } from "./write-lock.js";
12
13
  export const CALL_EXIT_CODES = { ok: 0, failed: 1, unknownTool: 2, invalidInput: 10 };
13
14
  export const CLI_ARGS_MAX_BYTES = 1024 * 1024;
14
15
  function readBounded(fd, label) {
@@ -168,7 +169,33 @@ export async function runToolCall(toolName, options = {}) {
168
169
  try {
169
170
  return await Promise.race([
170
171
  invoke(),
171
- new Promise((resolve) => { timer = setTimeout(() => resolve({ status: "uncertain", blocker: "cli-timeout", nextAction: "read back the requestId before retrying; retry only after the outcome is established", retryable: true }), options.timeoutMs); }),
172
+ new Promise((resolve) => {
173
+ timer = setTimeout(() => {
174
+ // r30a F4/F5: lock is deliberately kept on cli-timeout (cli.ts),
175
+ // so retryable must NOT claim "retry now". Put lock state into
176
+ // the JSON envelope and give a pasteable request.status command.
177
+ const envName = typeof args.environment === "string" && args.environment.length > 0 ? args.environment : options.environment;
178
+ const lockHeldBy = typeof envName === "string" && envName.length > 0 ? lockOwner(envName) : null;
179
+ const requestId = typeof args.requestId === "string" ? args.requestId : options.requestId;
180
+ const envFlag = typeof envName === "string" && envName.length > 0 ? ` --env ${envName}` : "";
181
+ const statusCmd = typeof requestId === "string" && requestId.length > 0
182
+ ? `omc call request.status${envFlag} --args '{"requestId":"${requestId}"}'`
183
+ : `omc call request.status${envFlag} --args '{"requestId":"<本次 requestId>"}'`;
184
+ resolve({
185
+ status: "uncertain",
186
+ blocker: "cli-timeout",
187
+ frozen: true,
188
+ // Lock still held by this process until exit → not immediately retryable.
189
+ retryable: false,
190
+ retryableAfter: "process-exit",
191
+ retryAfter: lockHeldBy?.pid !== undefined ? `pid ${lockHeldBy.pid} exits` : "current process exits (stale lock auto-recovers)",
192
+ lockHeldBy,
193
+ nextAction: `${statusCmd};确认结果后再决定是否换 requestId 重试(锁未放前立刻重试会撞 write-lock-held)`,
194
+ nextCommand: statusCmd,
195
+ message: "outcome is uncertain: cli-timeout;写可能仍在飞,环境写锁故意未释放",
196
+ });
197
+ }, options.timeoutMs);
198
+ }),
172
199
  ]);
173
200
  }
174
201
  finally {
@@ -209,9 +236,15 @@ export async function runToolCall(toolName, options = {}) {
209
236
  * one raw + one escaped, read as "奇奇怪怪的乱码").
210
237
  */
211
238
  function humanResultLine(result) {
239
+ const status = String(result?.status ?? "ok");
240
+ // dry-run 的正文(含 summary)已经完整在信封里,再往 stderr 印一行就是重复载荷
241
+ // ——这正是 2026-10-14 Windows 那次「两行 JSON、一行还被转义」的根因。
242
+ // 2026-11-05 回归:`model.create` 的会话级 dry-run 探测开始在顶层带 summary,
243
+ // 于是这一行又被印出来(round16 的「dry-run 不打印人话」断言失败)。
244
+ if (status === "dry-run")
245
+ return null;
212
246
  if (typeof result.summary === "string" && result.summary.trim().length > 0)
213
247
  return result.summary;
214
- const status = String(result?.status ?? "ok");
215
248
  if (status === "ok" || status === "dry-run")
216
249
  return null;
217
250
  if (result?.blocker === undefined && status !== "error" && status !== "uncertain")
@@ -24,7 +24,7 @@ const SURFACES = [
24
24
  operations: ["discover", "fingerprint"],
25
25
  requires: [],
26
26
  intent: { level: "none", note: "治理面,不进 intent;用 omc setup / discover_environment。" },
27
- notes: "只读;写入开关与生产授权在 omc setup。",
27
+ notes: "只读;写入开关与生产授权在 omc setup。discover/fingerprint 回 detectedVersion/inAnchorFamily/versionSource(与 doctor 同源)。",
28
28
  },
29
29
  {
30
30
  id: "app",
@@ -88,6 +88,8 @@ const SURFACES = [
88
88
  description: "列表视图设计时表头与列生命周期。",
89
89
  operations: ["create", "configure", "delete"],
90
90
  intent: { level: "partial", note: "create/configure 与 delete(absent:true)已声明式。" },
91
+ // r30f F5:partial 不能只剩一个词——说清「哪一半可用 / 哪一半要实跑」。
92
+ notes: "coverage=partial:create/configure/get/list/delete 可用;1.1.x 上 delete/absent 与默认视图短路需实跑,勿当 full。",
91
93
  },
92
94
  {
93
95
  id: "datarule",
@@ -97,7 +99,7 @@ const SURFACES = [
97
99
  operations: ["create", "update", "delete"],
98
100
  requires: ["field"],
99
101
  intent: { level: "partial", note: "声明式支持字段级规则 create/update 与 delete(absent:true);规则类型 1..5 与 options 按平台校验器闭环。" },
100
- notes: "目标字段必须已发布。",
102
+ notes: "目标字段必须已发布。coverage=partial:list/create/update/delete 可用;type3/4/5 设计态行≠运行时 options(运行时优先 field.update);跨版本请实跑。",
101
103
  },
102
104
  {
103
105
  id: "businessrule",
@@ -166,7 +168,7 @@ const SURFACES = [
166
168
  description: "报表页 + BI 图表:报表树/元数据读取、报表页建改删、BI dashboard 配置、图表增改删、图表数据查询、数据集读取/关联。",
167
169
  operations: ["tree", "get", "chartdata", "datasource", "create", "update", "delete", "configure"],
168
170
  intent: { level: "partial", note: "顶层 reports[] 声明式 create/update + dashboard configure(global/attributes/charts diff,removeUnlistedCharts 删未声明图表);delete 走 omc call report.delete,datasource.link/chartdata 走 omc call。" },
169
- notes: "报表页走平台通道 /api/api/report/*,BI dashboard/图表/数据走 /dashboard/v1/*(无 errcode 信封;body 必带 config:{token,reportCode,appCode},缺则写操作静默 no-op)。BI 外层 corpId 是真实查询键(错值→dashboard.not.exist,空→corpid.empty),默认 \"a\",可用 corpId 覆盖。",
171
+ notes: "coverage=partial:tree/get/create/update/configure/delete 与 datasource.link/chartdata 可用;BI(/dashboard/v1/*) 缺 config 会静默 no-op,跨版本请实跑。报表页走 /api/api/report/*;BI body 必带 config:{token,reportCode,appCode}。BI 外层 corpId 是真实查询键(错值→dashboard.not.exist,空→corpid.empty),默认 \"a\",可用 corpId 覆盖。",
170
172
  },
171
173
  {
172
174
  id: "data",
@@ -297,6 +299,16 @@ export const BOUNDARY_CUES = Object.freeze({
297
299
  cues: ["子表套子表", "嵌套子表", "多级子表"],
298
300
  note: "嵌套子表(子表套子表)在 W6-S27 为 blocked;一级子表可用,嵌套不可",
299
301
  },
302
+ /**
303
+ * r31c F27: four near-synonyms for row-level data scope all returned 0 help
304
+ * hits. Surface as a boundary so `omc next "申请人只看自己的单据"` answers
305
+ * honestly instead of routing to workflow.permissions (who may originate).
306
+ */
307
+ "row-level-data-permission": {
308
+ status: "unsupported",
309
+ cues: ["只看自己", "只能看自己", "本人单据", "行级权限", "行级数据权限", "数据权限", "可见范围", "dataScope", "按登录人过滤"],
310
+ note: "行级数据权限 / 可见范围 / 申请人只看自己的单据不在工具覆盖内(没有按登录人动态过滤的读面/写面)。workflow.permissions 只管谁能发起;org.role.setUserScope 是角色成员部门管理范围;listview.conditions 是全局过滤。如实报告能力缺口,不要用发起权限/列表条件伪造",
311
+ },
300
312
  /**
301
313
  * r19 p14(2026-10-26 用户裁决:报表**暂不优先支持、先隐藏**,不是「平台
302
314
  * 不支持」):报表分两层——平台层 `/api/api/report/*`(树/改名/存在性)在
@@ -322,15 +334,17 @@ export function boundaryOf(goal) {
322
334
  }
323
335
  const CAPABILITY_TOPICS = Object.freeze({
324
336
  environment: ["omc-start-here", "omc-write-hazards"],
325
- app: ["omc-business-configuration", "omc-write-hazards"],
337
+ // r29a F4:应用参数矩阵挂 app-scope(合成 topic,见 skills.ts)。
338
+ app: ["app-scope", "omc-business-configuration", "omc-write-hazards"],
339
+ folder: ["app-scope", "omc-business-configuration"],
326
340
  dictionary: ["omc-business-configuration", "omc-tool-selection"],
327
- model: ["omc-business-configuration", "omc-config-draft-review"],
341
+ model: ["app-scope", "omc-business-configuration", "omc-config-draft-review"],
328
342
  field: ["omc-business-configuration", "omc-tool-selection"],
329
343
  form: ["omc-business-configuration", "omc-config-draft-review"],
330
344
  listview: ["omc-business-configuration", "omc-tool-selection"],
331
345
  datarule: ["omc-business-configuration", "omc-refusals"],
332
- businessrule: ["omc-business-configuration", "omc-refusals"],
333
- "rule-lifecycle": ["omc-business-configuration", "omc-refusals"],
346
+ businessrule: ["app-scope", "omc-business-configuration", "omc-refusals"],
347
+ "rule-lifecycle": ["app-scope", "omc-business-configuration", "omc-refusals"],
334
348
  workflow: ["omc-business-configuration", "omc-refusals"],
335
349
  onlinejs: ["omc-business-configuration", "omc-write-hazards"],
336
350
  bizservice: ["omc-business-configuration", "omc-write-hazards"],
@@ -264,10 +264,12 @@ export function nextCommandFor(result) {
264
264
  * accidents (0.6.6/0.6.7): `meta.get_status` → `"failed"`, `rollback apply` →
265
265
  * `"stopped"` both used to land in `internal` before they were added here.
266
266
  */
267
- export const RESULT_STATUSES = ["ok", "dry-run", "partial", "unknown-tool", "uncertain", "refused", "failed", "stopped", "blocked", "error"];
267
+ export const RESULT_STATUSES = ["ok", "dry-run", "partial", "unknown-tool", "uncertain", "refused", "failed", "stopped", "blocked", "skipped", "error"];
268
268
  export const RESULT_STATUS_ROUTES = Object.freeze({
269
269
  ok: { kind: "success", envelopeStatus: "ok", exitCode: CLI_EXIT_CODES.ok },
270
270
  "dry-run": { kind: "success", envelopeStatus: "dry-run", exitCode: CLI_EXIT_CODES.ok },
271
+ // r30a F12: irreversible rollback apply is an expected answer, not an error.
272
+ skipped: { kind: "success", envelopeStatus: "skipped", exitCode: CLI_EXIT_CODES.ok },
271
273
  partial: { kind: "partial", envelopeStatus: "partial", code: "partial-batch", exitCode: 1 },
272
274
  "unknown-tool": { kind: "unknown-tool", envelopeStatus: "error", code: "invalid-input", exitCode: CLI_EXIT_CODES.invalidInput },
273
275
  uncertain: { kind: "uncertain", envelopeStatus: "uncertain", code: "uncertain", exitCode: CLI_EXIT_CODES.uncertain },
@@ -363,9 +365,13 @@ export function classifyResult(result) {
363
365
  };
364
366
  }
365
367
  if (route?.kind === "uncertain") {
368
+ // r30a F5: honour the producer's retryable flag (cli-timeout keeps the
369
+ // write lock → retryable:false). Default remains true for other uncertains
370
+ // that do not carry an explicit flag.
371
+ const retryable = result.retryable === false ? false : true;
366
372
  return {
367
373
  status: route.envelopeStatus,
368
- error: withNext({ code: route.code, message: String(result.message ?? result.reason ?? "outcome is uncertain"), retryable: true }),
374
+ error: withNext({ code: route.code, message: String(result.message ?? result.reason ?? "outcome is uncertain"), retryable }),
369
375
  exitCode: route.exitCode,
370
376
  };
371
377
  }