@stalfh233/omc-cli 0.4.99 → 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,310 @@
1
+ /**
2
+ * r19 tickets 11–13 — 复合写命令。
3
+ *
4
+ * 16 轮账本里三对相邻调用(提交→读回 157 次 / 保存规则→启用 120 次 / 更新流
5
+ * 程模板→发布 93 次):第二步是忘得最快、忘了就静默不生效的地方。三条命令把
6
+ * 各自的两步压成一次调用,两阶段各自回报。
7
+ *
8
+ * 共同纪律(与 omc call 的边界:这里封装的是**业务意图与顺序**,不是批量处理
9
+ * ——批处理仍走 call --args-list / omc script):
10
+ * - 默认 dry-run,显式 --apply 才写;
11
+ * - 第一步失败立即停:提交绝不自动重试(写不可重放)、保存失败绝不启用半成
12
+ * 品、更新失败绝不发布;
13
+ * - 阶段性结果如实回报:启用失败报「已保存但未启用」,发布失败保留草稿。
14
+ *
15
+ * 测试注入:invoke(默认走 runToolCall)与 emit(默认写 CLI 信封),进程内测
16
+ * 试用 harness.call 直连,不需要起服务器。
17
+ */
18
+ import { runToolCall } from "./call.js";
19
+ function defaultInvoke(environment) {
20
+ return async (tool, args) => {
21
+ const { result } = await runToolCall(tool, {
22
+ args: { ...args, ...(environment !== undefined && args.environment === undefined ? { environment } : {}) },
23
+ ...(environment !== undefined ? { environment } : {}),
24
+ print: () => undefined,
25
+ });
26
+ return result;
27
+ };
28
+ }
29
+ function isFailure(result) {
30
+ const status = String(result.status ?? "ok");
31
+ return status !== "ok" && status !== "dry-run";
32
+ }
33
+ /**
34
+ * 提交差异归一:平台读回可能把 {id} 展开成对象、数字存成字符串。比较双方先
35
+ * 解包到可比形式,只有真正不同才进 diff(假阴性比噪音 diff 危险,假阳性
36
+ * diff 只是浪费注意力)。
37
+ */
38
+ function normalizeForDiff(value) {
39
+ if (value === null || value === undefined)
40
+ return "";
41
+ if (Array.isArray(value))
42
+ return value.map((entry) => normalizeForDiff(entry));
43
+ if (typeof value === "object") {
44
+ const record = value;
45
+ if (typeof record.id === "string" && Object.keys(record).length <= 3)
46
+ return record.id;
47
+ return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, normalizeForDiff(entry)]));
48
+ }
49
+ return typeof value === "string" ? value.trim() : value;
50
+ }
51
+ function equalAfterNormalize(sent, got) {
52
+ const left = normalizeForDiff(sent);
53
+ const right = normalizeForDiff(got);
54
+ if (left === right)
55
+ return true;
56
+ try {
57
+ return JSON.stringify(left) === JSON.stringify(right);
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ function diffSubmittedAgainstLoaded(sent, loaded) {
64
+ const diff = {};
65
+ if (!loaded || typeof loaded !== "object")
66
+ return diff;
67
+ for (const [key, value] of Object.entries(sent)) {
68
+ const got = loaded[key];
69
+ if (!equalAfterNormalize(value, got))
70
+ diff[key] = { sent: value, got: got ?? null };
71
+ }
72
+ return diff;
73
+ }
74
+ function bizObjectIdOf(submitResult) {
75
+ const readback = (submitResult.readback ?? {});
76
+ const submittedData = (readback.submittedData ?? {});
77
+ const performed = (submitResult.performed ?? {});
78
+ for (const candidate of [readback.objectId, readback.bizObjectId, submittedData.bizObjectId, submittedData.id, performed.bizObjectId]) {
79
+ if (typeof candidate === "string" && candidate.length > 0)
80
+ return candidate;
81
+ }
82
+ return undefined;
83
+ }
84
+ export async function runSubmitVerify(options) {
85
+ const invoke = options.invoke ?? defaultInvoke(options.environment);
86
+ const emit = options.emit ?? (() => undefined);
87
+ if (!options.schema) {
88
+ emit({ status: "error", blocker: "invalid-input", message: "--schema 必填" }, "error");
89
+ return 1;
90
+ }
91
+ if (options.rows.length === 0) {
92
+ emit({ status: "error", blocker: "invalid-input", message: "--rows 至少一条(JSON 数组或单对象)" }, "error");
93
+ return 1;
94
+ }
95
+ if (!options.apply) {
96
+ emit({
97
+ status: "ok",
98
+ dryRun: true,
99
+ schemaCode: options.schema,
100
+ plannedRows: options.rows.length,
101
+ rowKeys: options.rows.map((row) => Object.keys(row)),
102
+ note: "dry-run 仅预览;--apply 逐条 data.submit → 用真实 id data.load → 回报差异。提交失败立即停(不重试、不降级);差异是发现不是失败。批处理(不需要读回)用 omc call data.submit --args-list。",
103
+ }, "ok");
104
+ return 0;
105
+ }
106
+ const rows = [];
107
+ let verified = 0;
108
+ let mismatched = 0;
109
+ for (let index = 0; index < options.rows.length; index += 1) {
110
+ const formData = options.rows[index];
111
+ // 提交不可重放:一次失败立即停——重试同参数在写路径上是危险的乐观。
112
+ const submit = await invoke("data.submit", { schemaCode: options.schema, formData });
113
+ if (isFailure(submit)) {
114
+ emit({
115
+ status: submit.status,
116
+ schemaCode: options.schema,
117
+ stoppedAtIndex: index,
118
+ rows,
119
+ retried: false,
120
+ failure: { index, tool: "data.submit", result: submit },
121
+ message: `第 ${index + 1} 条提交被拒/失败——立即停止,未重试、未降级;前面 ${index} 条已提交的各自见 rows`,
122
+ }, String(submit.status));
123
+ return 1;
124
+ }
125
+ const bizObjectId = bizObjectIdOf(submit);
126
+ if (bizObjectId === undefined) {
127
+ emit({
128
+ status: "error",
129
+ schemaCode: options.schema,
130
+ stoppedAtIndex: index,
131
+ rows,
132
+ retried: false,
133
+ failure: { index, tool: "data.submit", result: submit, reason: "提交成功但读回没给 bizObjectId——无法读回验证" },
134
+ message: "提交成功但回执缺少 bizObjectId,无法读回(不猜 id)",
135
+ }, "error");
136
+ return 1;
137
+ }
138
+ const loaded = await invoke("data.load", { schemaCode: options.schema, bizObjectId });
139
+ const loadData = (loaded.data ?? loaded.content ?? null);
140
+ const diff = diffSubmittedAgainstLoaded(formData, loadData);
141
+ if (Object.keys(diff).length === 0)
142
+ verified += 1;
143
+ else
144
+ mismatched += 1;
145
+ rows.push({
146
+ index,
147
+ status: "ok",
148
+ bizObjectId,
149
+ verified: Object.keys(diff).length === 0,
150
+ diff,
151
+ });
152
+ }
153
+ emit({
154
+ status: "ok",
155
+ schemaCode: options.schema,
156
+ rows,
157
+ summary: { submitted: rows.length, verified, mismatched },
158
+ note: mismatched > 0 ? "有字段读回与提交不一致(见各行 diff)——差异是发现不是失败;常见原因:日期/关联字段平台侧归一化,先核对是否实质差异" : undefined,
159
+ }, "ok");
160
+ return 0;
161
+ }
162
+ export async function runRuleSaveEnable(options) {
163
+ const invoke = options.invoke ?? defaultInvoke(options.environment);
164
+ const emit = options.emit ?? (() => undefined);
165
+ if (!options.schema || !options.ruleCode || !options.name || !options.nodeGraph) {
166
+ emit({ status: "error", blocker: "invalid-input", message: "--schema/--rule-code/--name/--node-graph 全部必填" }, "error");
167
+ return 1;
168
+ }
169
+ if (!options.apply) {
170
+ emit({
171
+ status: "ok",
172
+ dryRun: true,
173
+ schemaCode: options.schema,
174
+ ruleCode: options.ruleCode,
175
+ phases: ["rule.save(upsert)", "rule.enable"],
176
+ note: "dry-run 仅预览;--apply 先保存、保存成功才启用。规则保存后默认禁用——不启用就不会运行(16 轮账本里这步相邻遗漏 120 次)。",
177
+ }, "ok");
178
+ return 0;
179
+ }
180
+ const save = await invoke("rule.save", {
181
+ schemaCode: options.schema,
182
+ ruleCode: options.ruleCode,
183
+ name: options.name,
184
+ nodeGraph: options.nodeGraph,
185
+ ...(options.publish === true ? { publish: true } : {}),
186
+ });
187
+ if (isFailure(save)) {
188
+ emit({
189
+ status: save.status,
190
+ phase: "save",
191
+ willRun: false,
192
+ saved: save,
193
+ message: "保存失败——规则未落库,绝不启用半成品;修正 nodeGraph 后重跑",
194
+ }, String(save.status));
195
+ return 1;
196
+ }
197
+ const enable = await invoke("rule.enable", { schemaCode: options.schema, ruleCode: options.ruleCode });
198
+ if (isFailure(enable)) {
199
+ emit({
200
+ status: enable.status,
201
+ phase: "enable",
202
+ willRun: false,
203
+ saved: save,
204
+ enabled: enable,
205
+ message: "已保存但未启用——规则现在不会运行;排查启用失败原因后单跑 omc call rule.enable(草稿已保留,不需要重保存)",
206
+ }, String(enable.status));
207
+ return 1;
208
+ }
209
+ emit({
210
+ status: "ok",
211
+ schemaCode: options.schema,
212
+ ruleCode: options.ruleCode,
213
+ saved: save,
214
+ enabled: enable,
215
+ willRun: true,
216
+ answer: "会运行:规则已保存且已启用",
217
+ }, "ok");
218
+ return 0;
219
+ }
220
+ /**
221
+ * 合并语义显式化:先读当前草稿,算出新模板没覆盖的旧节点。
222
+ * - 默认(无 --confirm-replace):旧节点**保留**——追加进最终模板(插在 END
223
+ * 节点之前),回执 preservedNodes 列明;
224
+ * - --confirm-replace:按调用者模板原样发送,旧节点被丢弃,回执
225
+ * droppedNodes 列明。
226
+ * 两种路径都不依赖平台的合并/整替行为:最终模板由本命令显式合成。
227
+ */
228
+ function composeFinalTemplate(draftActivities, template, confirmReplace) {
229
+ const newCodes = new Set((template.activities ?? []).map((activity) => String(activity.activityCode ?? "")));
230
+ const oldNodes = draftActivities.filter((activity) => {
231
+ const code = String(activity.activityCode ?? "");
232
+ return code !== "" && !newCodes.has(code);
233
+ });
234
+ const oldCodes = oldNodes.map((activity) => String(activity.activityCode ?? ""));
235
+ if (confirmReplace || oldCodes.length === 0) {
236
+ return { finalTemplate: template, preservedNodes: [], droppedNodes: confirmReplace ? oldCodes : [] };
237
+ }
238
+ const activities = template.activities ?? [];
239
+ const endIndex = activities.findIndex((activity) => String(activity.activityType ?? "").toUpperCase() === "END");
240
+ const merged = endIndex >= 0
241
+ ? [...activities.slice(0, endIndex), ...oldNodes, ...activities.slice(endIndex)]
242
+ : [...activities, ...oldNodes];
243
+ return { finalTemplate: { ...template, activities: merged }, preservedNodes: oldCodes, droppedNodes: [] };
244
+ }
245
+ export async function runWorkflowUpdatePublish(options) {
246
+ const invoke = options.invoke ?? defaultInvoke(options.environment);
247
+ const emit = options.emit ?? (() => undefined);
248
+ if (!options.schema || !options.code || !options.template) {
249
+ emit({ status: "error", blocker: "invalid-input", message: "--schema/--code/--template 必填" }, "error");
250
+ return 1;
251
+ }
252
+ const draft = options.apply ? await invoke("workflow.get_draft", { schemaCode: options.schema, ...(options.code ? { code: options.code } : {}) }) : { status: "ok", data: null };
253
+ const draftData = (draft.data ?? null);
254
+ const draftActivities = (draftData?.activities ?? draftData?.template?.activities ?? []);
255
+ const { finalTemplate, preservedNodes, droppedNodes } = composeFinalTemplate(draftActivities, options.template, options.confirmReplace === true);
256
+ if (!options.apply) {
257
+ emit({
258
+ status: "ok",
259
+ dryRun: true,
260
+ schemaCode: options.schema,
261
+ workflowCode: options.code,
262
+ preservedNodes,
263
+ droppedNodes: options.confirmReplace === true ? droppedNodes : [],
264
+ finalTemplate,
265
+ note: "dry-run 仅合成预览;--apply 走 workflow.update(上面的 finalTemplate)→ workflow.publish。默认保留旧节点(preservedNodes);--confirm-replace 才允许丢弃(droppedNodes)。",
266
+ }, "ok");
267
+ return 0;
268
+ }
269
+ const update = await invoke("workflow.update", { schemaCode: options.schema, code: options.code, template: finalTemplate });
270
+ if (isFailure(update)) {
271
+ emit({
272
+ status: update.status,
273
+ phase: "update",
274
+ update,
275
+ message: "更新失败——立即停止,未发布;草稿保持原样",
276
+ }, String(update.status));
277
+ return 1;
278
+ }
279
+ const publish = await invoke("workflow.publish", { schemaCode: options.schema, code: options.code });
280
+ if (isFailure(publish)) {
281
+ emit({
282
+ status: publish.status,
283
+ phase: "publish",
284
+ update,
285
+ published: publish,
286
+ preservedNodes,
287
+ droppedNodes,
288
+ message: "已更新未发布——模板改动保留在草稿;发布失败原因见 published,处理后单跑 omc call workflow.publish",
289
+ }, String(publish.status));
290
+ return 1;
291
+ }
292
+ emit({
293
+ status: "ok",
294
+ schemaCode: options.schema,
295
+ workflowCode: options.code,
296
+ update,
297
+ published: publish,
298
+ finalTemplate,
299
+ preservedNodes,
300
+ droppedNodes,
301
+ ...(preservedNodes.length > 0
302
+ ? { mergeWarning: `合并语义:旧节点 ${preservedNodes.join("、")} 不在新模板里、默认保留(已追加进 finalTemplate)。要丢弃请用 --confirm-replace 重跑。` }
303
+ : {}),
304
+ ...(options.confirmReplace === true && droppedNodes.length > 0
305
+ ? { replaceWarning: `确认替换:旧节点 ${droppedNodes.join("、")} 已被丢弃(不在 finalTemplate 里)。` }
306
+ : {}),
307
+ }, "ok");
308
+ return 0;
309
+ }
310
+ //# sourceMappingURL=compound-writes.js.map
package/dist/discovery.js CHANGED
@@ -88,17 +88,57 @@ export function commandInventory(registry, options = {}) {
88
88
  };
89
89
  }
90
90
  /**
91
- * Bare `omc commands` (no `--json`, no `--compact`, no scope): a bounded INDEX,
92
- * not the 54 KB catalogue.
91
+ * r19 ticket 05 — semantic `commands --filter`.
93
92
  *
94
- * Round 9 heavy-task measurement (sandbox 38): an agent exploring cold ran bare
95
- * `commands` 4× at ~54 KB each — ~216 KB, 28% of the whole task — even though
96
- * the full rows are the fallback face and `next`/`--compact --lane` exist. The
97
- * bytes were paid before any hint could be read. So the DEFAULT face is now the
98
- * lean index (name/lane/kind + lane counts + how to expand); the full rows
99
- * require the documented machine entry `commands --json`, and one capability is
100
- * `commands --json --compact --lane <lane>`.
93
+ * The old filter matched only `name + lane + capability id` substrings, so an
94
+ * agent asking in capability language(「聚合」「字典」「业务集成」)got an
95
+ * empty list plus the full governance prose and had to re-read the 20 KB
96
+ * catalogue. The semantic face matches, in authority order: tool name →
97
+ * capability aliases/title/id → recipe id/title/aliases → tool description,
98
+ * with case and separator (`.`/`-`/`_`/space) normalization on both sides.
99
+ * Deterministic: hits sort by matched-field authority, then name.
101
100
  */
101
+ export function filterTools(registry, query) {
102
+ const rawQuery = query.trim().toLowerCase();
103
+ if (rawQuery.length === 0)
104
+ return [];
105
+ const normalizedQuery = normalizeFaceText(query);
106
+ const routeByLane = capabilityRouteIndex(registry).routeByLane;
107
+ const hits = [];
108
+ for (const tool of registry) {
109
+ const capability = tool.lane ? routeByLane.get(tool.lane) : undefined;
110
+ const fields = [
111
+ { label: "工具名", text: tool.name },
112
+ ...(capability ? [
113
+ { label: `能力 ${capability.id} 别名`, text: capability.aliases.join(" ") },
114
+ { label: `能力 ${capability.id} 标题`, text: capability.title },
115
+ { label: "能力 id", text: capability.id },
116
+ ] : []),
117
+ ...(tool.executionMetadata?.recipes ?? []).flatMap((recipe) => [
118
+ { label: `配方 ${recipe.id} 标题`, text: recipe.title },
119
+ { label: "配方 id", text: recipe.id },
120
+ { label: `配方 ${recipe.id} 别名`, text: (recipe.aliases ?? []).join(" ") },
121
+ ]),
122
+ { label: "工具摘要", text: tool.description },
123
+ ];
124
+ let via;
125
+ let rank = 0;
126
+ for (const field of fields) {
127
+ const lower = field.text.toLowerCase();
128
+ if (lower.includes(rawQuery) || normalizeFaceText(field.text).includes(normalizedQuery)) {
129
+ via = `${field.label}含 "${query.trim()}"`;
130
+ break;
131
+ }
132
+ rank += 1;
133
+ }
134
+ if (via !== undefined)
135
+ hits.push({ name: tool.name, via, rank });
136
+ }
137
+ return hits.sort((left, right) => left.rank - right.rank || left.name.localeCompare(right.name)).map(({ name, via }) => ({ name, via }));
138
+ }
139
+ function normalizeFaceText(text) {
140
+ return text.toLowerCase().replace(/[._\s-]/g, "");
141
+ }
102
142
  export function commandIndex(registry) {
103
143
  const byLane = new Map();
104
144
  for (const tool of registry) {
@@ -177,6 +217,178 @@ function exampleList(tool) {
177
217
  requiresPriorReadback: recipe.requiresEnvironmentDerivedIds,
178
218
  }));
179
219
  }
220
+ /**
221
+ * `firstCall`: one runnable minimal shape, inline.
222
+ *
223
+ * r18 measured 71 adjacent `describe <tool>` → `describe <tool> --example <id>`
224
+ * pairs across 16 rounds: the compact default named the recipes but withheld
225
+ * the args, so learning the shape cost a second turn. The default now carries
226
+ * the cheapest *offline-valid* recipe (deterministic: status, then args bytes,
227
+ * then id) with its args inlined when small enough to stay a cheap face.
228
+ *
229
+ * Two honesty rules, because a wrong shape here is worse than no shape:
230
+ * - shipped recipes use stand-in ids (`omc_recipe_model`, `<environment>`).
231
+ * They are rewritten to explicit `<...>` placeholders and listed in
232
+ * `missing`, so nothing pretends to be a legal value.
233
+ * - `executable` is true only when `missing` is empty. Today that is never,
234
+ * because every authored recipe is a template; the field exists so a future
235
+ * concrete recipe becomes executable without a shape change.
236
+ */
237
+ const RECIPE_INLINE_ARGS_CEILING = 1_000;
238
+ const RECIPE_PLACEHOLDER_MODEL = "omc_recipe_model";
239
+ const RECIPE_PLACEHOLDER_ENV = "<environment>";
240
+ const PLACEHOLDER_SOURCES = {
241
+ environment: "omc env / 项目 .omc-env",
242
+ schemaCode: "omc call model.list(models[].schemaCode)",
243
+ workflowCode: "omc call workflow.get_published(workflow.code)",
244
+ bizObjectId: "data.save 读回的 performed.bizObjectId",
245
+ workflowInstanceId: "data.save 读回的 performed.workflowInstanceId",
246
+ workItemId: "data.save 读回的 performed.workItemId",
247
+ propertyCode: "omc call field.list(fields[].code)",
248
+ code: "任务要求;模型/应用内唯一",
249
+ name: "任务要求",
250
+ propertyType: "按 inputSchema / 任务要求选枚举值",
251
+ };
252
+ /**
253
+ * Keys whose value always comes from the caller's own environment, never from
254
+ * the recipe. A recipe may name a concrete example (`wms_material`), but
255
+ * presenting that as the first call would send a write at the wrong model —
256
+ * the exact failure mode this projection exists to avoid. The literal is
257
+ * rewritten to `<key>` and named in `missing` with its source.
258
+ */
259
+ const CONTEXT_KEYS = new Set([
260
+ "environment",
261
+ "schemaCode",
262
+ "schemaCodes",
263
+ "sheetCode",
264
+ "workflowCode",
265
+ "bizObjectId",
266
+ "bizObjectIds",
267
+ "workflowInstanceId",
268
+ "workItemId",
269
+ "propertyCode",
270
+ "propertyCodes",
271
+ "appCode",
272
+ "parentCode",
273
+ // Service/dictionary/form identifiers are also tenant-specific: a sample
274
+ // `wms_material_service` pasted into another environment fails at the
275
+ // platform, not at the CLI gate.
276
+ "serviceCode",
277
+ "serviceMethodCode",
278
+ "serviceMethodParameterCode",
279
+ "dictionaryCode",
280
+ "recordCode",
281
+ "formCode",
282
+ "formSchemaCode",
283
+ ]);
284
+ function contextValue(key) {
285
+ return key === "environment" ? "<env>" : `<${key}>`;
286
+ }
287
+ function hasPlaceholder(value) {
288
+ if (typeof value === "string") {
289
+ return value.includes(RECIPE_PLACEHOLDER_MODEL) || value === RECIPE_PLACEHOLDER_ENV || /<[^<>]+>/.test(value);
290
+ }
291
+ if (Array.isArray(value))
292
+ return value.some(hasPlaceholder);
293
+ if (value && typeof value === "object")
294
+ return Object.values(value).some(hasPlaceholder);
295
+ return false;
296
+ }
297
+ /** Replace stand-in ids with explicit placeholders; never invent a real-looking id. */
298
+ function replacePlaceholders(value, key) {
299
+ if (key !== undefined && CONTEXT_KEYS.has(key)) {
300
+ return Array.isArray(value) ? [contextValue(key)] : contextValue(key);
301
+ }
302
+ if (typeof value === "string")
303
+ return value.split(RECIPE_PLACEHOLDER_MODEL).join("<existing-model-schemaCode>");
304
+ if (Array.isArray(value))
305
+ return value.map((entry) => replacePlaceholders(entry));
306
+ if (value && typeof value === "object") {
307
+ const output = {};
308
+ for (const [entryKey, entry] of Object.entries(value))
309
+ output[entryKey] = replacePlaceholders(entry, entryKey);
310
+ return output;
311
+ }
312
+ return value;
313
+ }
314
+ function placeholderKeys(args) {
315
+ return Object.entries(args)
316
+ .filter(([key, value]) => CONTEXT_KEYS.has(key) || hasPlaceholder(value))
317
+ .map(([key]) => key)
318
+ .sort();
319
+ }
320
+ function trimPreconditions(preconditions) {
321
+ const kept = [];
322
+ let bytes = 0;
323
+ for (const entry of preconditions) {
324
+ const next = bytes + Buffer.byteLength(entry, "utf8") + 4;
325
+ if (kept.length >= 3 || next > 420)
326
+ break;
327
+ kept.push(entry);
328
+ bytes = next;
329
+ }
330
+ return kept;
331
+ }
332
+ function selectRecipe(recipes) {
333
+ if (recipes.length === 0)
334
+ return undefined;
335
+ const ranked = [...recipes].sort((left, right) => {
336
+ const leftRank = left.status === "offline-valid" ? 0 : 1;
337
+ const rightRank = right.status === "offline-valid" ? 0 : 1;
338
+ if (leftRank !== rightRank)
339
+ return leftRank - rightRank;
340
+ const leftBytes = Buffer.byteLength(JSON.stringify(left.args), "utf8");
341
+ const rightBytes = Buffer.byteLength(JSON.stringify(right.args), "utf8");
342
+ if (leftBytes !== rightBytes)
343
+ return leftBytes - rightBytes;
344
+ return left.id.localeCompare(right.id);
345
+ });
346
+ return ranked[0];
347
+ }
348
+ function firstCallFor(tool) {
349
+ const recipe = selectRecipe(tool.executionMetadata?.recipes ?? []);
350
+ if (!recipe)
351
+ return undefined;
352
+ const rawArgs = recipe.args;
353
+ const hoistedEnvironment = "environment" in rawArgs;
354
+ const withoutEnv = {};
355
+ for (const [key, value] of Object.entries(rawArgs))
356
+ if (key !== "environment")
357
+ withoutEnv[key] = value;
358
+ const inlined = replacePlaceholders(withoutEnv);
359
+ const inlinedJson = JSON.stringify(inlined);
360
+ const argsBytes = Buffer.byteLength(inlinedJson, "utf8");
361
+ const missing = [...(hoistedEnvironment ? ["environment"] : []), ...placeholderKeys(withoutEnv)];
362
+ // A single quote cannot ride inside `--args '<json>'`; fall back to the file
363
+ // form rather than emit a command that is subtly wrong when pasted.
364
+ const inline = argsBytes <= RECIPE_INLINE_ARGS_CEILING;
365
+ // Above the one-liner size, `args` already carries the shape; repeating it as
366
+ // a shell-quoted `command` doubles the bytes for no new information.
367
+ const inlineCommand = inline && argsBytes <= 512 && !inlinedJson.includes("'");
368
+ const firstCall = {
369
+ recipeId: recipe.id,
370
+ ...(inline
371
+ ? {
372
+ args: inlined,
373
+ ...(inlineCommand
374
+ ? { command: `omc call ${tool.name}${hoistedEnvironment ? " --env <env>" : ""} --args '${inlinedJson}'` }
375
+ : { commandHint: `把上面的 args 原样作为 --args '<json>' 传给 omc call ${tool.name}${hoistedEnvironment ? " --env <env>" : ""}` }),
376
+ note: "args 里的标识(模型/服务/字段 code)来自示例环境,需换成你自己的",
377
+ }
378
+ : { argsBytes, note: `配方参数 ${argsBytes}B,超过默认内联上限;用 readMore 取完整形状(不要凭这一行猜 args)` }),
379
+ executable: missing.length === 0,
380
+ ...(missing.length > 0 ? { missing } : {}),
381
+ ...(missing.length > 0
382
+ ? {
383
+ requiredValues: Object.fromEntries(missing.map((key) => [key, { value: key === "environment" ? "<env>" : `<${key}>`, source: PLACEHOLDER_SOURCES[key] ?? "按 inputSchema 或任务要求" }])),
384
+ }
385
+ : {}),
386
+ preconditions: trimPreconditions(recipe.preconditions),
387
+ requiresPriorReadback: recipe.requiresEnvironmentDerivedIds,
388
+ readMore: `omc describe ${tool.name} --example ${recipe.id}`,
389
+ };
390
+ return firstCall;
391
+ }
180
392
  /** Default `omc describe`: the compact contract projection. */
181
393
  export function describeTool(registry, name) {
182
394
  const tool = registry.find((entry) => entry.name === name);
@@ -186,6 +398,7 @@ export function describeTool(registry, name) {
186
398
  const { required, params } = compactParams(schema);
187
399
  const examples = exampleList(tool);
188
400
  const hasRecipe = (tool.executionMetadata?.recipes.length ?? 0) > 0;
401
+ const firstCall = firstCallFor(tool);
189
402
  return {
190
403
  name: tool.name,
191
404
  lane: tool.lane ?? null,
@@ -194,6 +407,7 @@ export function describeTool(registry, name) {
194
407
  required,
195
408
  params,
196
409
  examples,
410
+ ...(firstCall !== undefined ? { firstCall } : {}),
197
411
  // Point cold callers at the cheap runnable face BEFORE they reach for
198
412
  // --full: a recipe is ~1–2 KB and a single --param is a few hundred bytes,
199
413
  // while --full is 5–20 KB. Benchmark (bench13): two --full calls were 34 KB