@stalfh233/omc-cli 0.4.98 → 0.4.100

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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Shape-family refusals: the payload was malformed in a way the CLI can see
3
+ * offline. Each cites the recipe that carries the correct shape.
4
+ */
5
+ const SHAPE_GATES = Object.freeze({
6
+ "relation-single-array-shape": {
7
+ whatWentWrong: "单选关联字段(propertyType 9)传了数组",
8
+ correctShape: "单选关联只接受 id 字符串或 {id} 对象:{\"字段\":\"<关联行id>\"} 或 {\"字段\":{\"id\":\"<id>\"}};多选关联(11)/人员(50)/组织(51)才用数组",
9
+ recipeCommand: { tool: "data.submit", recipe: "plain-data-submit" },
10
+ doNot: "不要对单选关联继续传数组重试——平台稳定回 50000,不是偶发",
11
+ },
12
+ "submit-date-shape": {
13
+ whatWentWrong: "日期字段的值形状不对(毫秒时间戳会落库 NULL)",
14
+ correctShape: "字符串且匹配字段 format:日期字段 \"YYYY-MM-DD\";日期时间字段 ISO8601 带时区 \"2026-01-01T10:00:00+08:00\"",
15
+ recipeCommand: { tool: "data.submit", recipe: "plain-data-submit" },
16
+ doNot: "不要传毫秒数(如 1790000000000)——不报错但落库 NULL,读回才发现",
17
+ },
18
+ "submit-child-rows-missing-id": {
19
+ whatWentWrong: "更新已有记录时,子表行没带 id",
20
+ correctShape: "更新时子表行必须带平台 id(新增行才省略);无 id 的行会被当**新增**插入,不判重",
21
+ command: undefined,
22
+ recipeCommand: { tool: "data.update", recipe: "plain-data-submit" },
23
+ doNot: "不要用同一批无 id 子行重试 update——每次都会再插一遍(r17 实测 2 行变 8 行)",
24
+ },
25
+ "submit-approval-agree-misplaced": {
26
+ whatWentWrong: "agree 放在了 approval 对象里,平台只读顶层 agree",
27
+ correctShape: "通过/驳回用顶层参数:{\"agree\": true|false, \"approvalNote\": \"意见\"};approval 对象只是评论载体(content/relUsers)",
28
+ recipeCommand: { tool: "data.submit", recipe: "workflow-backed-data-submit" },
29
+ doNot: "approval.agree 会被静默忽略且仍按通过处理(r18b 真机:agree:false 照样 APPROVE)——原地重试必然得到同样的错误结果",
30
+ },
31
+ "submit-approval-opinion-misplaced": {
32
+ whatWentWrong: "opinion 放在了 approval 对象里,平台不读它",
33
+ correctShape: "审批意见用顶层 approvalNote;approval.content 是评论内容,不是审批意见",
34
+ recipeCommand: { tool: "data.submit", recipe: "workflow-backed-data-submit" },
35
+ doNot: "改 approval.opinion 重试无意义——字段被静默忽略",
36
+ },
37
+ "aggregation-self-schema-cycle": {
38
+ whatWentWrong: "聚合目标是自己的子表——平台 publishFormula 必然判环(checkCycleOfAggregation:1032),无条件 305003",
39
+ correctShape: "聚合 schemaCode 指向**另一个模型**的主表标量列;要子表合计先物化到来源主表再聚合",
40
+ recipeCommand: { tool: "field.create", recipe: "formula-aggregation-cross-model" },
41
+ doNot: "同模型子表聚合是平台死路(8.6.26 与 1.1.25 逐行相同),改 payload 无用;也不要删平台自动生成的 t100 CALCULATE_RULE 镜像行",
42
+ },
43
+ "aggregation-target-is-child-schema": {
44
+ whatWentWrong: "聚合指向了子表 schema",
45
+ correctShape: "aggregation.schemaCode 必须是来源模型主表;数据项只能是来源主表的标量列",
46
+ recipeCommand: { tool: "field.create", recipe: "formula-aggregation-cross-model" },
47
+ doNot: "聚合指向子表会让目标模型整个不可写(40000 无明细)且匹配恒 0——先删这个字段配置再继续",
48
+ },
49
+ "aggregation-data-item-not-scalar": {
50
+ whatWentWrong: "聚合数据项不是来源主表的标量列",
51
+ correctShape: "跨模型汇总的数据项只能是来源主表标量列;子表列先在来源模型物化成主表字段",
52
+ recipeCommand: { tool: "field.create", recipe: "formula-aggregation-cross-model" },
53
+ doNot: "直接把子表列写进数据项不会报配置错误——回算静默恒 0,先回来源模型物化",
54
+ },
55
+ "aggregation-large-missing-k": {
56
+ whatWentWrong: "LARGE/SMALL 聚合缺 k(前 N 名的 N)",
57
+ correctShape: "algorithmOptions 里带 k(正整数);LARGE=前 k 大,SMALL=前 k 小",
58
+ recipeCommand: { tool: "field.create", recipe: "formula-aggregation-cross-model" },
59
+ doNot: "缺 k 时平台抛 NumberFormatException 掩码成 50000——重试同参数无用",
60
+ },
61
+ "aggregation-union-result-type": {
62
+ whatWentWrong: "UNION(文本拼接)的 resultType 越界",
63
+ correctShape: "UNION 的 resultType 只能 0 或 1(拼接分隔行为),不是数值型枚举",
64
+ recipeCommand: { tool: "field.create", recipe: "formula-aggregation-cross-model" },
65
+ doNot: "写 2 及以上平台直接 50000——改回 0/1 再发布",
66
+ },
67
+ "rule-graph-validation-failed": {
68
+ whatWentWrong: "规则图形状没过写前校验",
69
+ correctShape: "nodeGraph:nodes(Start/动作/End,系统节点标 isSystemNode)+ routes(preNode→postNode);条件分支用 BRANCH 节点",
70
+ recipeCommand: { tool: "rule.save", recipe: "business-rule-branch-conditional" },
71
+ },
72
+ "rule-graph-routes-empty": {
73
+ whatWentWrong: "规则图缺 routes(节点连线)",
74
+ correctShape: "每条 route {preNode, postNode, routeCondition:true};Start→首节点→…→End 必须连通",
75
+ recipeCommand: { tool: "rule.save", recipe: "business-rule-branch-conditional" },
76
+ },
77
+ "rule-graph-bizaction-mapping-field-missing": {
78
+ whatWentWrong: "BIZ_ACTION 节点的输入映射缺 serviceMethodParameterCode 或 bizCode",
79
+ correctShape: "inputMappings 每条:serviceMethodParameterCode(服务方法参数)↔ bizCode(模型字段 code),codeType 按类型",
80
+ recipeCommand: { tool: "rule.save", recipe: "rule-cross-model-create" },
81
+ missingInputs: [
82
+ { name: "serviceCode/serviceMethodCode", source: "omc call bizservice.list --args '{}'(已注册的二开服务)" },
83
+ { name: "bizCode(模型字段)", source: "omc call field.list --args '{\"schemaCode\":\"<模型>\",\"all\":true}'" },
84
+ ],
85
+ },
86
+ "rule-graph-bizmethod-unresolved": {
87
+ whatWentWrong: "规则图引用的二开服务方法不存在或未注册",
88
+ correctShape: "methodMapping.schemaCode/serviceCode/serviceMethodCode 必须是已注册服务的真实 code",
89
+ recipeCommand: { tool: "rule.save", recipe: "rule-cross-model-create" },
90
+ missingInputs: [
91
+ { name: "serviceCode/serviceMethodCode", source: "omc call bizservice.list --args '{}';方法级详情用 bizservice.get" },
92
+ ],
93
+ doNot: "不要凭记忆拼写服务 code——静默不匹配时规则保存成功但运行为空",
94
+ },
95
+ "field-relation-target-missing": {
96
+ whatWentWrong: "关联字段的目标模型缺失或不可解析",
97
+ correctShape: "关联字段 options 里目标模型 schemaCode 必须已存在且已发布",
98
+ missingInputs: [
99
+ { name: "目标模型 schemaCode", source: "omc call model.list --args '{}'" },
100
+ ],
101
+ },
102
+ });
103
+ /**
104
+ * Platform/permission/environment families: honest "fix this first" with NO
105
+ * retry command — a command that would fail again is worse than none.
106
+ */
107
+ const FIX_FIRST_GATES = Object.freeze({
108
+ "workflow-start-permission-denied": "40009 有三个独立同码原因:① 发起人角色没挂该流程的发起权限(ouScope ∩ 发起人部门链,全空会静默回退发起人);② 流程未启用/未发布;③ 模型未绑定流程。先 omc call workflow.get_published 核对 ②,再查角色 ouScope——不要反复换参数重试 data.submit",
109
+ "environment-write-disabled": "当前环境 profile 不可写:换可写环境,或让维护者在配置里开 writable——CLI 侧没有绕过开关",
110
+ "production-confirmation-required": "生产环境的写需要显式 confirmProduction:true——确认这次写入真的要落生产再加该参数,不要靠重试",
111
+ "app-not-published": "目标应用未发布:先 workflow/form/field 各面发布(或 app 级发布),再重试运行面操作",
112
+ "submit-workitem-not-mine": "该 workItem 不在当前登录人的待办里:用 omc call workitem.list --args '{\"status\":\"PENDING\"}' 拿**自己的** workItemId,不要用别人的 id 提交",
113
+ "capability-out-of-coverage": "需求不在覆盖面内:这份拒绝就是答案——用 omc capabilities 核对运行时清单,不要换相近工具伪造实现",
114
+ "version-family-evidence-missing": "平台版本证据缺失,写被关闭:先 omc doctor / discover_environment 取版本证据,非 8.6/1.1.25 家族按边界处理",
115
+ });
116
+ /** `tool-input-invalid` and friends: point at the refused tool's own describe. */
117
+ function inputInvalidGuidance(toolName) {
118
+ if (!toolName)
119
+ return undefined;
120
+ return {
121
+ whatWentWrong: "入参没过工具的 schema 校验(缺必填或类型不对)",
122
+ correctShape: "必填参数与类型见 command 指向的 describe(firstCall 自带最小可跑形状)",
123
+ command: `omc describe ${toolName}`,
124
+ doNot: "不要盲目重试——同参数必然同拒绝",
125
+ };
126
+ }
127
+ /**
128
+ * r19 p14 — the report BI boundary (`/dashboard/v1/*`). A deployment without
129
+ * the report service answers every BI call with a gateway 502 (live p9
130
+ * evidence); that is an ENVIRONMENT boundary, not a transient failure, so the
131
+ * teaching says which faces still work and to stop retrying. Matched from the
132
+ * message/detail text so both the refused and the frozen (uncertain) envelope
133
+ * paths can carry it.
134
+ */
135
+ function dashboardBoundaryGuidance() {
136
+ return {
137
+ whatWentWrong: "报表 BI 通道(/dashboard/v1/*)不可达——报表服务未启动(本机有此服务;网关 502,p9 实证)",
138
+ fixFirst: "报表能力暂不优先支持(维护者裁决,先隐藏):不要重试同一子面。平台层仍可用:report.tree / report.update / report.get 存在性判定;确需 BI 详情/数据源/图表/配置时,先启动本机报表服务再深进",
139
+ };
140
+ }
141
+ function mentionsDashboardChannel(payload) {
142
+ return JSON.stringify({ m: payload.message, d: payload.detail, r: payload.reason }).includes("/dashboard/v1/");
143
+ }
144
+ /**
145
+ * Resolve the authored recipe command against the registry: the guidance must
146
+ * never cite a recipe the registry does not ship (that would be a second,
147
+ * contradictory shape source). Falls back to the tool's describe; if even the
148
+ * tool is unknown, the command is omitted.
149
+ */
150
+ function resolveCommand(authored, registry) {
151
+ if (!authored.recipeCommand || !registry)
152
+ return undefined;
153
+ const tool = registry.find((entry) => entry.name === authored.recipeCommand.tool);
154
+ const recipe = tool?.executionMetadata?.recipes.find((entry) => entry.id === authored.recipeCommand.recipe);
155
+ if (tool && recipe)
156
+ return `omc describe ${tool.name} --example ${recipe.id}`;
157
+ if (tool)
158
+ return `omc describe ${tool.name}`;
159
+ return undefined;
160
+ }
161
+ export function refusalRecovery(blocker, payload, options = {}) {
162
+ // A refusal that already teaches (write-lock-held carries lock recovery) is
163
+ // not overwritten — single owner per envelope.
164
+ if (payload.recovery !== undefined)
165
+ return payload.recovery;
166
+ // r19 p14: the report BI boundary is matched from the wire path in the
167
+ // message/detail, so both refused and frozen (uncertain) envelopes teach it.
168
+ if (mentionsDashboardChannel(payload))
169
+ return dashboardBoundaryGuidance();
170
+ const shape = SHAPE_GATES[blocker];
171
+ if (shape) {
172
+ const command = resolveCommand(shape, options.registry);
173
+ const { recipeCommand: _drop, ...rest } = shape;
174
+ return { ...rest, ...(command !== undefined ? { command } : {}) };
175
+ }
176
+ const fixFirst = FIX_FIRST_GATES[blocker];
177
+ if (fixFirst)
178
+ return { whatWentWrong: String(payload.message ?? blocker).slice(0, 160), fixFirst };
179
+ if (blocker === "tool-input-invalid")
180
+ return inputInvalidGuidance(options.toolName);
181
+ return undefined;
182
+ }
183
+ /**
184
+ * Attach guidance to a refused result at the `runToolCall` choke point.
185
+ * Idempotent, refuses to overwrite, and leaves non-refused results untouched.
186
+ */
187
+ export function attachRefusalRecovery(result, toolName, registry) {
188
+ // r19 p14: `/dashboard/v1/*` failures surface as UNCERTAIN (frozen) — the
189
+ // http-status wire kind is classified "uncertain" by design, and frozen
190
+ // envelopes carry `reason`/`detail` but NO `blocker` field. The dashboard
191
+ // boundary is definitive (gateway 502, report service not started), so those
192
+ // frozen envelopes get the teaching too; every other uncertain result stays
193
+ // untouched (never relabel network jitter as an environment boundary).
194
+ const frozenWithBoundary = result.status === "uncertain" && mentionsDashboardChannel(result);
195
+ if (result.status !== "refused" && !frozenWithBoundary)
196
+ return result;
197
+ if (result.recovery !== undefined)
198
+ return result;
199
+ const blocker = typeof result.blocker === "string" ? result.blocker : "";
200
+ if (!blocker && !frozenWithBoundary)
201
+ return result;
202
+ const recovery = refusalRecovery(blocker, result, { registry, toolName });
203
+ if (!recovery)
204
+ return result;
205
+ return { ...result, recovery };
206
+ }
207
+ //# sourceMappingURL=refusal-guidance.js.map
package/dist/skills.js CHANGED
@@ -81,6 +81,22 @@ function scanHeadings(lines) {
81
81
  }
82
82
  return found;
83
83
  }
84
+ /**
85
+ * Slice one heading's body using the documented rule: a section runs until the
86
+ * next heading of the same or a higher level. Shared by `--section` and search
87
+ * so a search hit can announce the exact bytes `--section` would return.
88
+ */
89
+ function sliceSectionText(lines, start, level) {
90
+ let end = lines.length;
91
+ for (let index = start + 1; index < lines.length; index += 1) {
92
+ const match = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[index]);
93
+ if (match && match[1].length <= level) {
94
+ end = index;
95
+ break;
96
+ }
97
+ }
98
+ return lines.slice(start, end).join("\n").trim() + "\n";
99
+ }
84
100
  /** Return one Markdown heading and its nested content without loading the full topic into the envelope. */
85
101
  export function readSkillSection(name, requested) {
86
102
  const document = readSkill(name);
@@ -121,16 +137,7 @@ export function readSkillSection(name, requested) {
121
137
  if (start < 0)
122
138
  return { document, section: "", headings };
123
139
  const startEntry = scanned.find((entry) => entry.index === start);
124
- const level = startEntry.level;
125
- let end = lines.length;
126
- for (let index = start + 1; index < lines.length; index += 1) {
127
- const match = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[index]);
128
- if (match && match[1].length <= level) {
129
- end = index;
130
- break;
131
- }
132
- }
133
- return { document, section: lines.slice(start, end).join("\n").trim() + "\n", headings };
140
+ return { document, section: sliceSectionText(lines, startEntry.index, startEntry.level), headings };
134
141
  }
135
142
  /**
136
143
  * The "shape" of a topic: every heading with the bytes it would cost to read.
@@ -147,17 +154,7 @@ export function readSkillOutline(name) {
147
154
  const lines = document.content.split(/\r?\n/);
148
155
  const headings = scanHeadings(lines);
149
156
  const sections = headings.map((entry) => {
150
- let end = lines.length;
151
- // Same slicing rule as readSkillSection: a section runs until the next
152
- // heading of the same or a higher level, so reported bytes match the read.
153
- for (let index = entry.index + 1; index < lines.length; index += 1) {
154
- const match = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[index]);
155
- if (match && match[1].length <= entry.level) {
156
- end = index;
157
- break;
158
- }
159
- }
160
- const body = `${lines.slice(entry.index, end).join("\n").trim()}\n`;
157
+ const body = sliceSectionText(lines, entry.index, entry.level);
161
158
  return { heading: entry.heading, level: entry.level, bytes: Buffer.byteLength(body, "utf8") };
162
159
  });
163
160
  const bytes = Buffer.byteLength(document.content, "utf8");
@@ -170,4 +167,75 @@ export function readSkillOutline(name) {
170
167
  const visible = indexLevel === undefined ? sections : sections.filter((entry) => entry.level === indexLevel);
171
168
  return { document, bytes, sections: visible.length > 0 ? visible : sections };
172
169
  }
170
+ /**
171
+ * Cross-topic full-text search (`omc help --search`).
172
+ *
173
+ * Why this exists: r18 spent 173.8 KB re-dumping five whole topics twice to grep
174
+ * for `40000`, because `omc help` is a bookshelf (one heading index per topic)
175
+ * and there was no way to ask a question. A search that returns only
176
+ * topic/heading would still cost a second call, so a hit carries its section
177
+ * body and the exact bytes `--section` would return.
178
+ *
179
+ * Matching is deliberately literal: case-insensitive substring, whitespace
180
+ * splits terms, `all` requires every term in the same section and `any`
181
+ * requires one. No stemming, no fuzzy matching — an agent searching an exact
182
+ * error code must never be steered to a similar-looking section.
183
+ */
184
+ export function searchSkills(query, options) {
185
+ const terms = query.trim().toLowerCase().split(/\s+/).filter((term) => term.length > 0);
186
+ if (terms.length === 0)
187
+ return { matches: [], total: 0 };
188
+ const found = [];
189
+ for (const entry of listSkills()) {
190
+ const document = readSkill(entry.name);
191
+ if (!document)
192
+ continue;
193
+ const lines = document.content.split(/\r?\n/);
194
+ const scanned = scanHeadings(lines);
195
+ // Same visible levels as the index: a whole-document H1 must not swallow
196
+ // every H2 hit into one giant match.
197
+ const levels = [...new Set(scanned.map((item) => item.level))].sort((left, right) => left - right);
198
+ const singleRoot = levels.length > 1 && scanned.filter((item) => item.level === levels[0]).length === 1;
199
+ const indexLevel = singleRoot ? levels[1] : levels[0];
200
+ const visible = scanned.filter((heading) => heading.level === indexLevel);
201
+ for (const [ordinal, heading] of visible.entries()) {
202
+ if (indexLevel === undefined)
203
+ continue;
204
+ const section = sliceSectionText(lines, heading.index, heading.level);
205
+ const lower = section.toLowerCase();
206
+ const hitTerms = terms.filter((term) => lower.includes(term));
207
+ if (hitTerms.length === 0)
208
+ continue;
209
+ if (options.mode === "all" && hitTerms.length < terms.length)
210
+ continue;
211
+ const matchLines = [];
212
+ for (let index = heading.index; index < lines.length && matchLines.length < 3; index += 1) {
213
+ const text = lines[index];
214
+ const lineTerms = hitTerms.filter((term) => text.toLowerCase().includes(term));
215
+ if (lineTerms.length === 0)
216
+ continue;
217
+ if (matchLines.length > 0 && /^(#{1,6})\s+/.test(text))
218
+ continue;
219
+ matchLines.push({ line: index + 1, text: text.trim(), terms: lineTerms });
220
+ }
221
+ const headingHit = hitTerms.some((term) => heading.heading.toLowerCase().includes(term));
222
+ const bytes = Buffer.byteLength(section, "utf8");
223
+ found.push({
224
+ topic: document.name,
225
+ heading: heading.heading,
226
+ sectionOrdinal: ordinal + 1,
227
+ matchLines,
228
+ sectionBytes: bytes,
229
+ section,
230
+ // Heading hits beat body hits; more distinct terms beat fewer; smaller
231
+ // sections beat larger ones (cheaper to carry, usually more specific).
232
+ score: (headingHit ? 1000 : 0) + hitTerms.length * 100 + Math.max(0, 100 - Math.round(bytes / 64)),
233
+ });
234
+ }
235
+ }
236
+ found.sort((left, right) => right.score - left.score
237
+ || left.topic.localeCompare(right.topic)
238
+ || left.sectionOrdinal - right.sectionOrdinal);
239
+ return { matches: found.slice(0, Math.max(1, options.limit)), total: found.length };
240
+ }
173
241
  //# sourceMappingURL=skills.js.map
package/dist/tools.js CHANGED
@@ -21,6 +21,8 @@ import { registerListViewLane } from "./lanes/list-view.js";
21
21
  import { registerReportLane } from "./lanes/report.js";
22
22
  import { registerApplyChangesLane } from "./lanes/apply-changes.js";
23
23
  import { executionMetadataFor } from "./execution-metadata.js";
24
+ import { attachRefusalRecovery } from "./refusal-guidance.js";
25
+ import { ToolRefusalError } from "./tool-types.js";
24
26
  export { ToolRefusalError, ToolUncertainError } from "./tool-types.js";
25
27
  export { makeToolContext };
26
28
  /**
@@ -59,6 +61,39 @@ export function buildToolRegistry() {
59
61
  if (metadata)
60
62
  tool.executionMetadata = metadata;
61
63
  }
64
+ // r19 ticket 06 — 拒绝即教学,收口在最深的一层:每个 handler 返回/抛出的
65
+ // refused 结果在这里统一附加 recovery(同源配方命令、正确形状、不要做什么)。
66
+ // 收口在 registry 而不是 CLI 出口,是因为 runToolCall、测试 harness、MCP
67
+ // client、组装器全部经过 handler——一处附加、处处生效。已自带 recovery 的
68
+ // 拒绝(write-lock-held)不会被覆盖(attachRefusalRecovery 幂等)。
69
+ for (const tool of tools) {
70
+ const inner = tool.handler;
71
+ const name = tool.name;
72
+ tool.handler = async (ctx, args, self) => {
73
+ try {
74
+ const result = await inner(ctx, args, self);
75
+ if (result && typeof result === "object") {
76
+ const status = result.status;
77
+ // attachRefusalRecovery decides for itself: refused envelopes get
78
+ // guidance, uncertain ones only when the dashboard boundary is
79
+ // pinned in the frozen detail (r19 p14).
80
+ if (status === "refused" || status === "uncertain") {
81
+ return attachRefusalRecovery(result, name, tools);
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+ catch (error) {
87
+ const payload = error.payload;
88
+ if (payload && typeof payload === "object" && payload.status === "refused") {
89
+ const enriched = attachRefusalRecovery(payload, name, tools);
90
+ if (enriched !== payload)
91
+ throw new ToolRefusalError(enriched);
92
+ }
93
+ throw error;
94
+ }
95
+ };
96
+ }
62
97
  return tools;
63
98
  }
64
99
  export { loadCoverageManifest };
package/dist/usage-log.js CHANGED
@@ -34,6 +34,21 @@ const USAGE_LOG_MAX_BYTES = 8 * 1024 * 1024;
34
34
  * Memos still read `logPath()` only — a fallback log is append-only telemetry,
35
35
  * never a source of "already did this" answers.
36
36
  */
37
+ /**
38
+ * r19 ticket 02: explicit opt-out for the WRITE side of the ledger.
39
+ *
40
+ * A maintainer script or an investigation subprocess that runs read-only
41
+ * discovery faces inside a sandbox used to append to that round's
42
+ * `.omc/usage.jsonl` — the r18 probe was read as 469 calls where the agent
43
+ * made 184 (19 polling reads + 105 investigation reads). "What did this round
44
+ * cost" must mean "what did the AGENT spend": `OMC_NO_USAGE=1` mutes this
45
+ * invocation's records (project log, user fallback, explicit OMC_USAGE_LOG —
46
+ * the switch beats them all). Only the write entry points consult this guard;
47
+ * reading (`omc usage`, idempotency memos) is untouched.
48
+ */
49
+ function recordingDisabled() {
50
+ return process.env.OMC_NO_USAGE === "1";
51
+ }
37
52
  function recordLogPath() {
38
53
  const explicit = process.env.OMC_USAGE_LOG;
39
54
  if (explicit)
@@ -74,6 +89,8 @@ function appendRecord(path, record) {
74
89
  }
75
90
  /** Append a `script-call` record: one line of `omc script` = one inner tool call. */
76
91
  export function recordScriptCall(input) {
92
+ if (recordingDisabled())
93
+ return;
77
94
  const target = recordLogPath();
78
95
  if (!target)
79
96
  return;
@@ -209,7 +226,7 @@ function commandOf(argv) {
209
226
  for (let index = 2; index < argv.length && values.length < 2; index += 1) {
210
227
  const token = argv[index];
211
228
  if (token.startsWith("--")) {
212
- if (["--out", "--max-output-bytes", "--args", "--args-file", "--env", "--environment", "--request-id", "--timeout-ms", "--wait", "--path", "--top"].includes(token))
229
+ if (["--out", "--max-output-bytes", "--args", "--args-file", "--env", "--environment", "--request-id", "--timeout-ms", "--wait", "--path", "--top", "--search", "--search-mode", "--limit"].includes(token))
213
230
  index += 1;
214
231
  continue;
215
232
  }
@@ -227,7 +244,7 @@ function facesOf(argv) {
227
244
  // `omc artifact get <id> --jq <path>` showed up as a bare `artifact get` with
228
245
  // empty argKeys, so the cost report could not tell a 200 B projection from a
229
246
  // 44 KB pull — precisely the read path these flags exist to make cheap.
230
- const faces = ["--full", "--compact", "--json", "--schema-only", "--param", "--example", "--section", "--sections", "--out", "--quiet", "--jq", "--summary", "--keys", "--path-only", "--extract", "--extract-keys", "--records"];
247
+ const faces = ["--full", "--compact", "--json", "--schema-only", "--param", "--example", "--section", "--sections", "--search", "--search-mode", "--out", "--quiet", "--jq", "--summary", "--keys", "--path-only", "--extract", "--extract-keys", "--records"];
231
248
  return faces.filter((face) => argv.includes(face));
232
249
  }
233
250
  const safeArgumentKeys = new Set(["schemaCode", "propertyCode", "code", "workflowCode", "ruleCode", "sheetCode", "id", "dataRuleType", "propertyType", "published", "handler"]);
@@ -270,6 +287,8 @@ function summarizeArgs(argv) {
270
287
  return summary;
271
288
  }
272
289
  export function beginUsage(argv) {
290
+ if (recordingDisabled())
291
+ return;
273
292
  const target = recordLogPath();
274
293
  if (!target)
275
294
  return;
package/dist/write.js CHANGED
@@ -124,7 +124,7 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload) {
124
124
  const artifactRef = writeArtifact(ctx, `${tool.name}-${randomUUID().slice(0, 8)}.json`, artifact);
125
125
  return { status: executed.status, steps: executed.steps, failedStep: executed.failedStep, ...executed.envelope, ...(args.requestId ? { requestId: args.requestId } : {}), artifactFingerprint: artifactRef.detailSha256 };
126
126
  }
127
- const { performed, readback, steps } = executed;
127
+ const { performed, readback, steps, halfSuccess } = executed;
128
128
  // Readback gate (live 2026-10-17): a tool that reports `readback.present:false`
129
129
  // has NOT proven its own write — the platform accepted the request but the
130
130
  // object is absent from the authoritative face. "Called without error" must
@@ -177,7 +177,7 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload) {
177
177
  });
178
178
  rollbackRef = { seq: entry.seq, hint: `omc rollback plan ${entry.seq}(--env ${entry.environment})` };
179
179
  }
180
- return { status: "ok", deliveryState, performed, readback, ...(readbackWarning ? { readbackWarning } : {}), ...(steps !== undefined ? { steps } : {}), ...(args.requestId ? { requestId: args.requestId, idempotency: idempotencyDeclaration(tool.name) } : {}), ...(productionConfirmed ? { productionConfirmed: true } : {}), ...(rollbackRef ? { rollback: rollbackRef } : {}), summary: writeSummary(tool.name, args, { performed, readback }, "done"), detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256, artifactFingerprint: artifactRef.detailSha256 };
180
+ return { status: "ok", deliveryState, performed, readback, ...(readbackWarning ? { readbackWarning } : {}), ...(steps !== undefined ? { steps } : {}), ...(halfSuccess !== undefined ? { halfSuccess } : {}), ...(args.requestId ? { requestId: args.requestId, idempotency: idempotencyDeclaration(tool.name) } : {}), ...(productionConfirmed ? { productionConfirmed: true } : {}), ...(rollbackRef ? { rollback: rollbackRef } : {}), summary: writeSummary(tool.name, args, { performed, readback }, "done"), detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256, artifactFingerprint: artifactRef.detailSha256 };
181
181
  });
182
182
  }
183
183
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.98",
3
+ "version": "0.4.100",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,9 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@stalfh233/omc-fake-platform": "0.4.98",
53
+ "@stalfh233/omc-fake-platform": "0.4.100",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.98",
55
+ "omc-core": "0.4.100",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -156,7 +156,7 @@ rules:
156
156
  - **`queryConditions` 不能为空**:它是反向过滤器的唯一输入,空数组时回算日志照打但目标字段恒 0(r17 实测)。
157
157
  - **聚合目标必须是来源主表的标量列**(r18 2026-10-26 真机):`aggregation.schemaCode` 指向**子表 schema** 时能发布,但目标模型从此整个不可写(`data.submit`/`data.update` 全回 40000 无明细),且子表无父单单号列、匹配恒 0;`dataItemCode` 写 `<子表>.<子列>` 则 publish 回 309004。要子表合计:先在来源主模型物化一个数值字段,再聚合该标量。
158
158
  - **DYNAMIC 条件的两个 code 分属两侧,写反了静默 0**:`propertyCode` = 来源模型字段,`currentPropertyCode` = 目标模型字段;语义「来源行的 X 等于本目标行的 Y」。
159
- - **`propertyType`/`currentPropertyType`(BizPropertyType)与 `operatorType`(OperatorType)必须写整数**——两者都是 `@JsonCreator get(int)`:propertyType 0=SHORT_TEXT 2=NUMERICAL 3=DATE 9=WORK_SHEET…;operatorType 1=EQ 2=NEQ 3=GT 4=IT(小于)5=GTEQ 6=ITEQ(小于等于)9=IS_NULL 10=IS_NOT_NULL…。写名字会被 `ObjectMapperUtils` 抛 json processing exception,再被死循环校验掩码成 305003(工具接受名字并自动转码)。`calculateType` 相反,是**字符串**(SUM/COUNT/COUNTIF/SUMIF/AVG/MAX/MIN/LARGE/SMALL/UNION)。
159
+ - **`propertyType`/`currentPropertyType`(BizPropertyType)与 `operatorType`(OperatorType)必须写整数**——两者都是 `@JsonCreator get(int)`:propertyType 0=SHORT_TEXT 2=NUMERICAL 3=DATE 9=WORK_SHEET…;operatorType 1=EQ 2=NEQ 3=GT 4=IT(小于)5=GTEQ 6=ITEQ(小于等于)9=IS_NULL 10=IS_NOT_NULL…。写名字会被 `ObjectMapperUtils` 抛 json processing exception,再被死循环校验掩码成 305003(工具接受名字并自动转码)。`calculateType` 相反,是**字符串**(SUM/COUNT/COUNTIF/SUMIF/AVG/MAX/MIN/LARGE/SMALL/UNION);LARGE/SMALL 必须带 k(`LARGE({来源.列},2)`),UNION 是文本拼接、resultType 必须是 0/1。
160
160
  - **回算时机(r17b 2026-10-26 完整行为图谱)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠);② 来源行 submit/update/**delete** 触发 `RollUpEventListener`→`FormulaPropertySupport` 事件回算(FIXED+DYNAMIC 混用、多组 OR、COUNTIF/MAX、子表行级条件都验过);③ ⚠️ 事件回算**只在目标模型发布后几分钟内可靠**,之后静默停止(无报错)——`field.publish --force` 目标模型恢复。发布后平台自动生成 t100 `CALCULATE_RULE` 镜像行,别删。r17 真机:目标行 srcNo=C1、来源 C1 行 amt=100 → 读 100;再提交一条 C1/amt=5 → 读 105。
161
161
  - **普通数值字段 + 计算规则(type2)不能替代**:REST 路径永不执行(`CalculateHandler.java:50` 的 `forceExecuteCalculateRule` 从不置真),门户里才算。
162
162
  - **DYNAMIC 来源不要写点号**:`dataActions` 的 `currentDataItemValue` 写 `items.sealNo` 会被引擎当成**子表列集合**(DatasetImpl),标量目标收到集合 → 运行期 `submit/update` 报 50000(`WorkSheetBizField.fillClientContent: Incorrect value type`)。单跳关联字段直接写字段 code(引擎取 `.id`);子表列只能用于公式/聚合。`rule.save` 已写前拒绝(`rule-graph-create-dynamic-source-is-child-collection`)。
@@ -26,7 +26,7 @@ be treated as a second source. Read this, then follow the topics it names.
26
26
  - `build`/`modify` → 按阶梯内联的**写工具 + 必填参数**执行。
27
27
  - `scout`(能不能/怎么写/是否支持)→ 先探边界(`omc-capability-scouting`),别直接写。若 `next` 返回 **`boundary`**(`match.kind:"boundary"`,如 `ocr-e-signature`/`release-prod`/`inline-coding`),说明需求**不在覆盖内**(`write:false`):如实报告、用 `omc capabilities` 核对,**不要**用相近能力伪造实现。
28
28
  阶梯步骤已内联工具必填参数(`route.ladder[].tools`/`readTools`)与命中 recipe 的 `describe <tool> --example <id>`;据此直接执行——**不要再逐能力 `commands --lane`**,也不要先扫 `commands --json`/全部 `contract`/全部 `describe --full`。
29
- 方法论按 `route.readSections` 逐节读:对每条 `{topic, section}` 执行 `omc help <topic> --section "<section>"`(单节 0.4–3.4KB)。`omc help <topic>` 默认只回节索引(约 1KB,含每节字节数);**不要 `--all` 读整篇**(5–14KB,读一次就留在后续每一轮窗口)。
29
+ 方法论按 `route.readSections` 逐节读:对每条 `{topic, section}` 执行 `omc help <topic> --section "<section>"`(单节 0.4–3.4KB)。`omc help <topic>` 默认只回节索引(约 1KB,含每节字节数);**不要 `--all` 读整篇**(5–14KB,读一次就留在后续每一轮窗口)。**带着具体问题(错误码/跨能力语义/某个词)直接 `omc help --search "<词>"`**——跨全部 topic 全文检索,命中节的正文随结果内联返回,一次调用就是答案;**严禁自己写 `for t in …; do omc help $t --all | grep …` 循环**(r18 实测 12 调用/176KB,检索一次 8KB 搞定)。
30
30
  2. 写前只需读 `omc-config-draft-review`(人审门)与 `omc-five-piece-flow`(发现→草稿→权限→执行→回读);`omc-business-configuration` 按命中的能力分节读(见 `next` 的 `readSections`),不要整篇预取。
31
31
  3. 环境名以 `omc doctor` 的「环境=」或 `.omc-env` 为准;工具不确定先用默认 `describe`/`--example`,只有单个工具缺形状才用 `--full`;`describe` 带 `exampleHint`/`recipeHint` 时优先 `--example`。
32
32
  4. 只有工具不在 `next` 命中能力内、或要看 purpose/枚举时,才补一次 `commands --json --compact --lane <lane>`。非显然形状(规则图、流程模板、表单布局、RESTFUL)才单独 `describe --example`。
@@ -50,6 +50,7 @@ omc script - --env <env> <<< '{"tool":"model.list"}' # 多调链:NDJSON 每
50
50
  omc capability <selected-id> # only the selected capability
51
51
  omc describe <tool> --example <recipe-id> # recipe first; default describe otherwise
52
52
  omc help <topic> --section "<heading>" # one methodology section, not all
53
+ omc help --search "40000" # full-text search across all topics (answer in one call)
53
54
  ```
54
55
 
55
56
  Discovery precedes every write. 预算档位见 `omc-tool-selection` 的「Discovery 成本档位」;裸 `omc commands` 是有界索引,全量目录须显式 `commands --json`。
@@ -97,6 +97,16 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
97
97
 
98
98
  - **同一 `<env>` 的写调用必须串行发起**:`write-lock-held` 不只在"并行重放失败请求"时出现,也会在**并行发起两个无关写**时触发(实测:并行两条 `dictionary.save`、并行 `field.update`+`field.publish` 各失败一次)。读可并行,写不要。
99
99
 
100
+ ## 审批提交的真实形状(r18b 2026-10-26 真机,1.1.25)
101
+
102
+ | 参数 | 位置 | 语义 |
103
+ |---|---|---|
104
+ | `agree` | **顶层** | true=通过 / false=驳回;**缺省=true**(驳回后实例仍 PROCESSING,别当成功) |
105
+ | `approval.content` / `relUsers` / `resources` | approval 对象 | 意见 / 下一步审批人 / 附件 |
106
+ | `approval.agree`、`approval.opinion` | — | **静默忽略**(真机:agree:false 仍 APPROVE 且走完流程;opinion 不落库)。OMC 已写前拒绝 |
107
+
108
+ 链:`data.save`(draft→workItemId) → `data.submit`(顶层 agree) → 下一级待办。验收看 `sequenceStatus`。
109
+
100
110
  ## 计算规则(datarule)的取值时机
101
111
 
102
112
  - **字段计算规则(`datarule.create` `dataRuleType:2`)在 REST 路径上不会执行、也不会回填**(r17 2026-10-25 复核,AI-Platform 1.1.25):
@@ -112,15 +122,13 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
112
122
  **回算可靠性(重要)**:① 目标行**自己 submit** 时镜像规则一定算(最可靠);② 来源行 submit/update/delete 的事件回算**只在目标模型发布后几分钟内可靠**,之后静默停止(无报错,源变了目标不更新)——重新 `field.publish --force` 目标模型恢复。验收:发布后立刻改来源行并读目标字段;长期对账别依赖事件回算。
113
123
  - **同模型(含自己的子表)聚合不可用**:`field.publish` 必抛 305003 —— `publishFormula` 把相对码改写成子表父模型(= 本模型),
114
124
  `checkCycleOfAggregation:1032` 判成环。与 payload 无关,8.6.26 与 1.1.25 代码相同。OMC 已写前拒绝 `aggregation-self-schema-cycle`。
115
- - **聚合目标不能是任何子表 schema,数据项必须是来源主表的标量列**(r18 2026-10-26 真机,代价约 40 次调用):
116
- `aggregation.schemaCode` 指向子表时**能发布**,但目标模型从此整个不可写 —— `data.submit`/`data.update` 全部回
117
- `40000 调用数据库发生异常`(无明细);且子表 schema 无父单单号列,按父单匹配恒为 0。
118
- `dataItemCode` 写成 `<子表>.<子列>` 则在 publish 回 309004「使用的聚合数据项不存在」。
119
- 正解:要子表合计,先在来源**主模型**物化一个数值字段(门户计算规则 / 写入方维护),再聚合该标量。
120
- OMC 已写前拒绝 `aggregation-target-is-child-schema`(读一次来源字段清单识别 parentId+sortKey)与
121
- `aggregation-data-item-not-scalar`(点号)。
122
- - **`40000` 的无明细掩码怎么查**:先怀疑字段级配置(上面的聚合/公式),用「删/禁一个公式字段再写一次」二分,
123
- 不要从表单/权限开始查。OMC 现在会把 40000 转成 `runtime-write-db-exception` 并列出该模型的公式字段。
125
+ - **聚合必须指向来源主表的标量列**(r18/r18b 真机):`schemaCode` 指向**子表 schema** 能发布,但目标模型从此整个不可写
126
+ (写入一律 `40000`,无明细),且子表无父单单号列、匹配恒 0;`dataItemCode` 写 `<子表>.<子列>` 则 publish 回 309004。
127
+ 要子表合计:先在来源主表物化一个数值字段,再聚合该标量。写前拒绝:`aggregation-target-is-child-schema` / `aggregation-data-item-not-scalar`。
128
+ - **`40000` 无明细时**先怀疑字段级配置(上面的聚合、公式序列化),用「删/禁一个公式字段再写一次」二分,别从表单/权限查。
129
+ OMC 会转成 `runtime-write-db-exception` 并列出该模型的公式字段。
130
+ - **`LARGE/SMALL` 必须带 k**:`LARGE({来源.列},2)`(缺 k → NumberFormatException → 写入 50000);
131
+ **`UNION` 是文本拼接**,顶层 `resultType` 必须 0/1(写 2 会 50000)。拒绝:`aggregation-large-missing-k` / `aggregation-union-result-type`。
124
132
  - **区别:`FORMULA`(计算公式,`propertyType:100`)字段的 EXPRESSION 模式仍只在前端算** —— API 写入不回填。
125
133
 
126
134
  ## 规则与业务集成