@stalfh233/omc-cli 0.6.19 → 0.6.20

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.
@@ -386,6 +386,17 @@ export function classifyResult(result) {
386
386
  const nextCommand = nextCommandFor(result) ?? (auth ? undefined : "omc help omc-refusals");
387
387
  return { status: route.envelopeStatus, error: nextCommand === undefined ? error : { ...error, nextCommand }, exitCode: auth ? CLI_EXIT_CODES.authConfig : CLI_EXIT_CODES.policyRefused };
388
388
  }
389
+ // Cross-project environment isolation is a local policy gate. It is emitted
390
+ // as an error-shaped result by the call seam so ordinary ConfigurationError
391
+ // failures (unknown env, missing credentials, malformed config) retain their
392
+ // historical classification.
393
+ if (result.blocker === "cross-environment-policy" || /^跨环境访问被拒绝:/u.test(String(result.message ?? ""))) {
394
+ return {
395
+ status: "error",
396
+ error: withNext({ code: "policy-refused", message: String(result.message ?? "cross-environment access refused"), retryable: false }),
397
+ exitCode: CLI_EXIT_CODES.policyRefused,
398
+ };
399
+ }
389
400
  // r25 沙盒(2026-11-04,B1 的同类):`meta.get_status` 返回 `status:"failed"`、
390
401
  // `rollback apply` 返回 `status:"stopped"`——两个都曾掉进 internal(exit 15):
391
402
  // 调用方看到「CLI 内部故障」,其实是「配置/凭据读回失败」或「恢复链在 N 步停下」。
package/dist/cli.js CHANGED
@@ -1666,7 +1666,7 @@ function cmdGraphReferences(kind, code, transport, requestedEnvironment) {
1666
1666
  }
1667
1667
  catch (error) {
1668
1668
  if (error instanceof GraphIndexAbsentError)
1669
- return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1669
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: `${error.message} 只读核查(不写索引):omc graph status --env <env>(当前配置根);MCP 面工具读的可能是另一配置根,比对 dbId/dbPath。`, nextCommand: "omc graph pull --env <env>" }, transport);
1670
1670
  return emitThrown(error, transport);
1671
1671
  }
1672
1672
  }
@@ -1680,7 +1680,7 @@ function cmdGraphStatus(transport) {
1680
1680
  }
1681
1681
  catch (error) {
1682
1682
  if (error instanceof GraphIndexAbsentError)
1683
- return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1683
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: `${error.message} 只读核查(不写索引):omc graph status --env <env>(当前配置根);MCP 面工具读的可能是另一配置根,比对 dbId/dbPath。`, nextCommand: "omc graph pull --env <env>" }, transport);
1684
1684
  return emitThrown(error, transport);
1685
1685
  }
1686
1686
  }
@@ -1693,7 +1693,7 @@ function cmdGraphImpact(kind, code, transport) {
1693
1693
  }
1694
1694
  catch (error) {
1695
1695
  if (error instanceof GraphIndexAbsentError)
1696
- return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1696
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: `${error.message} 只读核查(不写索引):omc graph status --env <env>(当前配置根);MCP 面工具读的可能是另一配置根,比对 dbId/dbPath。`, nextCommand: "omc graph pull --env <env>" }, transport);
1697
1697
  return emitThrown(error, transport);
1698
1698
  }
1699
1699
  }
@@ -1704,7 +1704,7 @@ function cmdGraphSearch(term, transport) {
1704
1704
  }
1705
1705
  catch (error) {
1706
1706
  if (error instanceof GraphIndexAbsentError)
1707
- return emitResult({ status: "refused", blocker: "graph-index-absent", message: error.message, nextCommand: "omc graph pull --env <env>" }, transport);
1707
+ return emitResult({ status: "refused", blocker: "graph-index-absent", message: `${error.message} 只读核查(不写索引):omc graph status --env <env>(当前配置根);MCP 面工具读的可能是另一配置根,比对 dbId/dbPath。`, nextCommand: "omc graph pull --env <env>" }, transport);
1708
1708
  return emitThrown(error, transport);
1709
1709
  }
1710
1710
  }
@@ -14,6 +14,12 @@ export function removedProfileKeys(profile) {
14
14
  return REMOVED_PROFILE_KEYS.filter((key) => raw[key] !== undefined);
15
15
  }
16
16
  export class ConfigurationError extends Error {
17
+ policyRefused;
18
+ constructor(message, options = {}) {
19
+ super(message);
20
+ this.name = "ConfigurationError";
21
+ this.policyRefused = options.policyRefused === true;
22
+ }
17
23
  }
18
24
  export function defaultPaths(projectDir = process.cwd()) {
19
25
  const configRoot = process.env.OMC_CONFIG_HOME
@@ -290,12 +296,25 @@ export class ConfigStore {
290
296
  const allowText = allowlist.length === 0
291
297
  ? `.omc-envs=(无或不存在;准入回退到 .omc-env ∪ 全局 default)`
292
298
  : `.omc-envs=${allowlist.join(",")}`;
299
+ const configuredEnvironments = this.readConfig().environments;
300
+ const candidates = Object.entries(configuredEnvironments)
301
+ .filter(([, profile]) => profile.applicationCode === name);
302
+ // If several environment names point at the same application, recommend
303
+ // the one the caller is already working in before falling back to the
304
+ // machine-wide configuration order. This avoids steering an agent from a
305
+ // project-local `local` environment to an unrelated `prod` entry merely
306
+ // because `prod` appears first in omc.config.json.
307
+ const applicationHint = candidates.find(([candidate]) => candidate === bound)?.[0]
308
+ ?? candidates.find(([candidate]) => allowlist.includes(candidate))?.[0]
309
+ ?? candidates.find(([, profile]) => profile.isDefault)?.[0]
310
+ ?? candidates[0]?.[0];
293
311
  throw new ConfigurationError(`跨环境访问被拒绝:环境 "${name}" 不属于本项目(本项目允许:${own.join("、") || "(none)"})。` +
294
312
  `解析到的项目根=${projectRoot};${boundText};${allowText}。` +
295
313
  `准入由「当前工作目录」下的 .omc-envs(白名单)+ .omc-env(默认绑定)共同决定,不是由 --env 参数单独决定;` +
296
314
  `在无声明的目录里 --env local 会落到机器全局 default(常见是 test)。` +
297
315
  `请改到声明了该环境的项目根再跑,或先向用户报告并取得同意后执行 \`omc env allow ${name}\`(写入项目 .omc-envs),` +
298
- `或临时设置 OMC_ALLOW_CROSS_ENV=1 单次放行。`);
316
+ `或临时设置 OMC_ALLOW_CROSS_ENV=1 单次放行。` +
317
+ (applicationHint === undefined ? "" : `\n也许你指的是 --env ${applicationHint}(app=${name})。`), { policyRefused: true });
299
318
  }
300
319
  bindProjectEnvironment(name) {
301
320
  const config = this.readConfig();
package/dist/mcp-graph.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
+ import { DatabaseSync } from "node:sqlite";
2
3
  import { existsSync } from "node:fs";
3
4
  import { resolve } from "node:path";
4
5
  import { createInterface } from "node:readline";
@@ -60,7 +61,7 @@ const EXPLORE_TOOL = {
60
61
  "一次查全一个目标的引用与影响面(本地索引,毫秒级)。",
61
62
  "输入 query 可为业务名(实体 name+code 子串)或规则 code+name 词首;也可直接给字段/模型/服务 code。kind 可选 field|model|service,不给则三种都查。",
62
63
  "返回 {index(freshness), references:{kind:[{schemaCode,ruleCode,nodeCode,nodeType,via}]}, impact:{kind:totals}, search:{matches:{model,field,service},rules}}。",
63
- "references/impact 支持 offset+limit 分页;truncated 时按返回的 nextOffset 继续调用。",
64
+ "references/impact 支持 offset+limit 分页;truncated 时按返回的 nextOffset 继续调用。实体分页结果带 nextCall;分页不会改变 resolved。字段同名时请用 schemaCode 消歧。",
64
65
  "改/删/重命名任何字段、模型、服务之前必查;找\"关于 X 的规则\"拿 code 也走这里。",
65
66
  "索引不含规则条件叶子与流程绑定;freshness=partial 时命中可能不完整(答案里有 gapWarning)。",
66
67
  ].join(" "),
@@ -69,6 +70,7 @@ const EXPLORE_TOOL = {
69
70
  properties: {
70
71
  query: { type: "string", description: "业务名(实体 name+code 子串)、字段/模型/服务 code,或规则检索词(规则 code+name 词首匹配)" },
71
72
  kind: { type: "string", enum: [...KINDS], description: "限定一种引用种类;省略则 field/model/service 全查" },
73
+ schemaCode: { type: "string", description: "限定字段所属模型;也可用于校验模型。" },
72
74
  env: { type: "string", description: "期望的环境名;与索引所属环境不一致时会拒绝(防跨环境误答)" },
73
75
  offset: { type: "integer", minimum: 0, description: "引用/实体结果起始偏移;truncated 时使用 nextOffset" },
74
76
  limit: { type: "integer", minimum: 1, maximum: MATCH_CAP, description: "每类结果最多返回多少条(默认 50)" },
@@ -78,7 +80,7 @@ const EXPLORE_TOOL = {
78
80
  };
79
81
  const STATUS_TOOL = {
80
82
  name: "omc_graph_status",
81
- description: "索引完整性与新鲜度:environmentName、freshness(complete|partial)、模型/规则计数、pulledAtIso、ageMinutes、staleAfterMinutes、dbPath、coverage.applicationCodes、gaps。超过阈值会给 warning;回答前先看一眼,索引缺失会给出 pull 指引。",
83
+ description: "索引完整性与新鲜度(所有身份与新鲜度字段同层):environmentName、freshness(complete|partial)、模型/规则计数、pulledAtIso、ageMinutes、staleAfterMinutes、dbId、dbPath、coverage、gaps。超过阈值会给 warning;索引缺失会给出 pull 指引。",
82
84
  inputSchema: { type: "object", properties: {} },
83
85
  };
84
86
  export function graphToolDefinitions() {
@@ -90,6 +92,57 @@ function toolResult(payload, isError = false) {
90
92
  function toolError(message) {
91
93
  return toolResult({ status: "error", message }, true);
92
94
  }
95
+ function withMatchedOn(row, query) {
96
+ const name = row.name ?? "";
97
+ const code = row.code ?? row.schemaCode ?? "";
98
+ const on = name.includes(query) ? "name" : "code";
99
+ const value = on === "name" ? name : code;
100
+ const at = value.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
101
+ if (at < 0)
102
+ return { ...row, matchedOn: { on, fragment: query } };
103
+ let start = at;
104
+ let end = at + query.length;
105
+ while (start > 0 && /[\p{L}\p{N}]/u.test(value[start - 1]))
106
+ start--;
107
+ while (end < value.length && /[\p{L}\p{N}]/u.test(value[end]))
108
+ end++;
109
+ return { ...row, matchedOn: { on, fragment: value.slice(start, end) } };
110
+ }
111
+ function levenshtein(a, b) {
112
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
113
+ for (let i = 1; i <= a.length; i++) {
114
+ let corner = prev[0];
115
+ prev[0] = i;
116
+ for (let j = 1; j <= b.length; j++) {
117
+ const old = prev[j];
118
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, corner + (a[i - 1] === b[j - 1] ? 0 : 1));
119
+ corner = old;
120
+ }
121
+ }
122
+ return prev[b.length];
123
+ }
124
+ function entityInventory() {
125
+ const db = new DatabaseSync(graphDbPath(), { readOnly: true });
126
+ try {
127
+ return [
128
+ ...db.prepare("SELECT schema_code, name FROM models").all().map((r) => ({ kind: "model", schemaCode: r.schema_code, code: r.schema_code, name: r.name ?? "" })),
129
+ ...db.prepare("SELECT schema_code, code, name FROM fields").all().map((r) => ({ kind: "field", schemaCode: r.schema_code, code: r.code, name: r.name ?? "" })),
130
+ ...db.prepare("SELECT code, name FROM services").all().map((r) => ({ kind: "service", code: r.code, name: r.name ?? "" })),
131
+ ];
132
+ }
133
+ finally {
134
+ db.close();
135
+ }
136
+ }
137
+ function suggestions(query) {
138
+ return entityInventory().map((row) => {
139
+ const q = query.toLocaleLowerCase();
140
+ const values = [row.code, row.name].map((v) => v.toLocaleLowerCase());
141
+ const prefix = values.some((v) => v.startsWith(q)) ? 0 : values.some((v) => v.includes(q)) ? 1 : 2;
142
+ const distance = Math.min(...values.map((v) => levenshtein(q, v)));
143
+ return { kind: row.kind, code: row.code, name: row.name, score: prefix * 1000 + distance };
144
+ }).sort((a, b) => a.score - b.score || a.code.localeCompare(b.code)).slice(0, 3);
145
+ }
93
146
  function cap(rows, offset = 0, limit = MATCH_CAP) {
94
147
  const page = rows.slice(offset, offset + limit);
95
148
  return { rows: page, truncated: offset + page.length < rows.length, offset, limit, ...(offset + page.length < rows.length ? { nextOffset: offset + page.length } : {}), total: rows.length };
@@ -100,7 +153,9 @@ export async function executeGraphTool(name, rawArgs) {
100
153
  try {
101
154
  if (name === STATUS_TOOL.name) {
102
155
  const status = readGraphStatus();
103
- return toolResult({ status: "ok", ...status });
156
+ // Keep a tiny compatibility locator for older MCP clients; all identity,
157
+ // freshness, coverage and gaps fields live at the top level.
158
+ return toolResult({ status: "ok", ...status, ...status.index, index: { environmentName: status.index.environmentName } });
104
159
  }
105
160
  if (name === EXPLORE_TOOL.name) {
106
161
  const query = typeof args.query === "string" ? args.query.trim() : "";
@@ -109,6 +164,9 @@ export async function executeGraphTool(name, rawArgs) {
109
164
  const kind = args.kind === undefined ? undefined : String(args.kind);
110
165
  if (kind !== undefined && !KINDS.includes(kind))
111
166
  return toolError(`kind 必须是 ${KINDS.join("|")} 之一`);
167
+ const schemaCode = typeof args.schemaCode === "string" && args.schemaCode.trim() ? args.schemaCode.trim() : undefined;
168
+ if (schemaCode && kind !== "field" && kind !== "model")
169
+ return toolError("schemaCode 仅适用于 kind=field|model");
112
170
  const status = readGraphStatus();
113
171
  const index = status.index;
114
172
  if (typeof args.env === "string" && args.env.trim() && args.env.trim() !== index.environmentName) {
@@ -119,14 +177,32 @@ export async function executeGraphTool(name, rawArgs) {
119
177
  const limit = typeof args.limit === "number" && Number.isSafeInteger(args.limit) && args.limit > 0 && args.limit <= MATCH_CAP ? args.limit : MATCH_CAP;
120
178
  const references = {};
121
179
  const impact = {};
180
+ // Resolve from an unpaged search. The result page is presentation only;
181
+ // otherwise offset=2 can silently change the subject being inspected.
182
+ const search = searchGraphRules(query, limit, offset);
183
+ const resolvedSearch = searchGraphRules(query, MATCH_CAP, 0);
184
+ const loweredQuery = query.toLocaleLowerCase();
185
+ const allCandidates = entityInventory().filter((r) => (r.code.toLocaleLowerCase().includes(loweredQuery) || r.name.toLocaleLowerCase().includes(loweredQuery)) &&
186
+ (!schemaCode || (r.kind !== "service" && r.schemaCode === schemaCode)));
187
+ const typedCandidates = kind ? allCandidates.filter((r) => r.kind === kind) : allCandidates;
188
+ const fieldCandidates = kind === "field" ? typedCandidates : allCandidates.filter((r) => r.kind === "field");
189
+ const exactSame = fieldCandidates.filter((r) => r.code === query);
190
+ const ambiguous = kind === "field" && !schemaCode && exactSame.length > 1;
191
+ const resolvedEntity = ambiguous ? undefined : typedCandidates[0] ?? (kind === undefined ? allCandidates[0] : undefined);
192
+ const subjectByName = query !== (resolvedEntity?.code ?? "") && resolvedEntity;
193
+ const subjects = resolvedEntity
194
+ ? [{ kind: resolvedEntity.kind, code: resolvedEntity.kind === "model" ? (resolvedEntity.schemaCode ?? resolvedEntity.code) : resolvedEntity.code }]
195
+ : [];
122
196
  for (const one of kinds) {
123
- const ref = queryGraphReferences(one, query);
124
- const capped = cap(ref.matches, offset, limit);
197
+ const subject = subjects.find((s) => s.kind === one);
198
+ const ref = queryGraphReferences(one, subject?.code ?? query);
199
+ const scopedMatches = subject && one === "field" && schemaCode ? ref.matches.filter((row) => row.schemaCode === schemaCode) : ref.matches;
200
+ const capped = cap(scopedMatches, offset, limit);
125
201
  // r35a-F4c(真机 2026-11-06):**MCP 面是第三个消费者**——CLI 的 references
126
202
  // 与 impact 都带 coverage 了,这里若不显式透传,MCP 用户看到的仍是
127
203
  // 「matches:[] + 一句旧 note」,照样会把「表单没扫」误读成「没人引用」。
128
204
  references[one] = {
129
- count: ref.matches.length,
205
+ count: scopedMatches.length,
130
206
  rows: capped.rows,
131
207
  offset: capped.offset,
132
208
  limit: capped.limit,
@@ -134,35 +210,49 @@ export async function executeGraphTool(name, rawArgs) {
134
210
  ...(ref.coverage ? { coverage: ref.coverage } : {}),
135
211
  ...(ref.gapWarning ? { gapWarning: ref.gapWarning } : {}),
136
212
  };
137
- if (ref.matches.length > 0 || kind !== undefined) {
138
- const imp = impactGraph(one, query);
213
+ if (scopedMatches.length > 0 || kind !== undefined || subject !== undefined) {
214
+ const imp = impactGraph(one, subject?.code ?? query);
215
+ const scopedRules = subject && one === "field" && schemaCode ? imp.rules.filter((row) => row.schemaCode === schemaCode) : imp.rules;
139
216
  impact[one] = {
140
- totals: imp.totals,
141
- rules: cap(imp.rules, offset, limit),
217
+ totals: scopedRules.length === imp.rules.length ? imp.totals : { ...imp.totals, rules: scopedRules.length },
218
+ rules: cap(scopedRules, offset, limit),
142
219
  datarules: cap(imp.datarules, offset, limit),
143
220
  listviews: cap(imp.listviews, offset, limit),
221
+ ...(one === "model" ? {
222
+ fieldsReferenced: [...imp.rules.flatMap((r) => r.touches.field.map((code) => ({ code, ruleCode: r.ruleCode }))).reduce((m, x) => {
223
+ const item = m.get(x.code) ?? { code: x.code, touchedByRules: [] };
224
+ if (!item.touchedByRules.includes(x.ruleCode))
225
+ item.touchedByRules.push(x.ruleCode);
226
+ m.set(x.code, item);
227
+ return m;
228
+ }, new Map()).values()],
229
+ } : {}),
144
230
  ...(imp.coverage ? { coverage: imp.coverage } : {}),
145
231
  };
146
232
  }
147
233
  }
148
- const search = searchGraphRules(query, limit, offset);
149
- const resolvedEntity = search.matches.model.items[0] ? { kind: "model", schemaCode: search.matches.model.items[0].schemaCode, code: search.matches.model.items[0].schemaCode } :
150
- search.matches.field.items[0] ? { kind: "field", schemaCode: search.matches.field.items[0].schemaCode, code: search.matches.field.items[0].code } :
151
- search.matches.service.items[0] ? { kind: "service", schemaCode: "", code: search.matches.service.items[0].code } :
152
- search.rules[0] ? { kind: "rule", schemaCode: search.rules[0].schemaCode, code: search.rules[0].ruleCode } : undefined;
234
+ const decorate = (row) => withMatchedOn({ ...row, ...(row.schemaCode && row.code === undefined ? { code: row.schemaCode } : {}) }, query);
235
+ const decoratedMatches = { ...search.matches, model: { ...search.matches.model, items: search.matches.model.items.map(decorate) }, field: { ...search.matches.field, items: search.matches.field.items.map(decorate) }, service: { ...search.matches.service, items: search.matches.service.items.map(decorate) } };
236
+ const noEntityHit = search.matches.model.total === 0 && search.matches.field.total === 0 && search.matches.service.total === 0;
237
+ const composite = /\s|\+|,|\b(?:AND|OR)\b/i.test(query);
153
238
  const searchNote = "实体检索:业务名/实体 name+code 使用子串匹配。\n规则检索:规则 code+name 使用词首匹配(中文子串不命中)。";
154
239
  const entityTruncated = Object.values(search.matches).some((match) => match.truncated === true);
155
- const paginationNote = entityTruncated ? `\n结果已截断:共 N 条时请使用对应 match 的 nextOffset,并在下一次调用传 offset=nextOffset、limit=${limit} 取下一段。` : "";
240
+ const nextCall = entityTruncated ? Object.fromEntries(Object.entries(search.matches).filter(([, m]) => m.truncated).map(([k, m]) => [k, { query, kind: k, offset: m.nextOffset, limit }])) : undefined;
241
+ const paginationNote = entityTruncated ? `\n结果已截断:请使用 nextCall 中对应调用继续。` : "";
156
242
  return toolResult({
157
243
  status: "ok",
158
244
  query,
159
245
  found: resolvedEntity !== undefined,
160
- ...(resolvedEntity ? { resolved: resolvedEntity } : {}),
246
+ ...(resolvedEntity ? { resolved: { kind: resolvedEntity.kind, schemaCode: "schemaCode" in resolvedEntity ? resolvedEntity.schemaCode : "", code: resolvedEntity.code, matchedOn: { on: resolvedEntity.name.includes(query) ? "name" : "code", fragment: query } } } : {}),
247
+ ...(ambiguous ? { ambiguous: true, candidates: exactSame.map((r) => ({ schemaCode: r.schemaCode, code: r.code, name: r.name })) } : {}),
161
248
  ...(kind !== undefined ? { kind } : {}),
162
249
  index,
163
250
  references,
164
251
  impact,
165
- search: { count: search.rules.length, rules: search.rules, matches: search.matches, coverage: search.coverage },
252
+ search: { count: search.rules.length, rules: search.rules, matches: decoratedMatches, coverage: search.coverage },
253
+ ...(nextCall ? { nextCall } : {}),
254
+ ...(noEntityHit && composite ? { unsupportedQuerySyntax: true, hint: "组合检索暂不支持,请拆词单查后求交" } : {}),
255
+ ...(noEntityHit ? { suggestions: suggestions(query), hint: "可尝试 suggestions 中的实体 code/name;引用明细请使用匹配 code 查 references/impact" } : {}),
166
256
  searchNote: `${searchNote}${paginationNote}`,
167
257
  ...(search.note ? { searchNote: `${searchNote}\n${search.note}` } : {}),
168
258
  // 旧句「索引不含规则条件叶子与流程绑定」漏了表单与流程权限(r35a-F4 实证
@@ -187,6 +187,14 @@ const CONTEXTUAL_KEY_GUIDANCE = Object.freeze({
187
187
  precision: "precision→options.scale(数值小数位,不是顶层 precision)",
188
188
  }),
189
189
  });
190
+ // A key can be valid for a sibling tool while still being invalid here. That
191
+ // is a capability-routing problem, not a spelling problem, so do not present
192
+ // the misleading "no close key" sentence. Keep this explicit and narrow: an
193
+ // arbitrary key must continue to use the ordinary closest-key hint.
194
+ const SIBLING_TOOL_KEY_HINTS = Object.freeze({
195
+ "app.update": Object.freeze({ name: '"name" 是 app.create 的入参;app.update 不收它' }),
196
+ "model.create": Object.freeze({ fields: '"fields" 是 configure_model 的入参;model.create 不收它' }),
197
+ });
190
198
  function closestSchemaKey(key, schemaKeys) {
191
199
  if (schemaKeys.has(key))
192
200
  return key;
@@ -249,6 +257,9 @@ export function parseToolInput(tool, input) {
249
257
  const guidance = TWO_STEP_GUIDANCE[tool.name]?.[key] ?? CONTEXTUAL_KEY_GUIDANCE[tool.name]?.[key];
250
258
  if (guidance !== undefined)
251
259
  return guidance;
260
+ const sibling = SIBLING_TOOL_KEY_HINTS[tool.name]?.[key];
261
+ if (sibling !== undefined)
262
+ return sibling;
252
263
  const semantic = KEY_SEMANTIC_HINTS[key];
253
264
  const contextual = contextualClosestKey(tool.name, key, schemaKeys);
254
265
  const closest = closestSchemaKey(key, schemaKeys);
@@ -259,14 +270,15 @@ export function parseToolInput(tool, input) {
259
270
  // 未知键的下一步应指向**正确的那个参数**,不是整份 describe(4–17 KB)。
260
271
  // 有近邻时 `--param <nearKey>` 是 150–900 B,且直接回答「本来想写哪个键」。
261
272
  // 语境建议优先于编辑距离(r34a-F18:data.delete 的 id 指 requestId 是噪声)。
273
+ const siblingHints = unknown.map((key) => SIBLING_TOOL_KEY_HINTS[tool.name]?.[key]).filter((hint) => typeof hint === "string");
262
274
  const nearestKey = contextualClosestKey(tool.name, unknown[0], schemaKeys) ?? closestSchemaKey(unknown[0], schemaKeys);
263
275
  const paramCommand = nearestKey !== undefined ? `omc describe ${tool.name} --param ${nearestKey}` : `omc describe ${tool.name}`;
264
276
  throw new ToolInputError({
265
277
  status: "error",
266
278
  blocker: "tool-input-unknown-keys",
267
279
  tool: tool.name,
268
- message: `写面工具不接受未知键(会被静默丢弃):${unknown.join("、")}。${twoStep.length > 0 ? twoStep.join(" ") : `${guidanceLines.length > 0 ? guidanceLines.join(";") : `最接近的合法键:${suggestions.join(";")}`}。参数表:${paramCommand}`}`,
269
- ...(twoStep.length > 0 || guidanceLines.length > 0 ? { nextCommand: `omc describe ${tool.name}` } : { nextCommand: paramCommand }),
280
+ message: `写面工具不接受未知键(会被静默丢弃):${unknown.join("、")}。${twoStep.length > 0 ? twoStep.join(" ") : siblingHints.length > 0 ? siblingHints.join(";") : `${guidanceLines.length > 0 ? guidanceLines.join(";") : `最接近的合法键:${suggestions.join(";")}`}。参数表:${paramCommand}`}`,
281
+ ...(twoStep.length > 0 || guidanceLines.length > 0 || siblingHints.length > 0 ? { nextCommand: `omc describe ${tool.name}` } : { nextCommand: paramCommand }),
270
282
  issues: unknown.map((key) => ({ path: [key], code: "unrecognized_key", message: issueHintFor(key) })),
271
283
  });
272
284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.6.19",
3
+ "version": "0.6.20",
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.19",
58
+ "omc-core": "0.6.20",
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.19"
76
+ "@stalfh233/omc-fake-platform": "0.6.20"
77
77
  }
78
78
  }
@@ -11,6 +11,11 @@ Grouped by family. For a specific tool's exact wire and valid shapes, follow the
11
11
  Read `result.blocker` (refused) or `result.status`+`result.reason` (uncertain).
12
12
  `result.retryable` is advisory only — the family decides what to do.
13
13
 
14
+ `status:"refused"` means a local preflight or policy gate stopped the call:
15
+ it necessarily sent zero platform writes; there is no need to second-guess
16
+ whether anything was written. A platform/wire error (`wire-business` or an
17
+ `errcode`) is different: the outcome can be uncertain, so use readback.
18
+
14
19
  ## 1. Write permission declined
15
20
 
16
21
  - `environment-write-disabled` — the environment profile has `writable:false`.
@@ -28,6 +28,11 @@ be treated as a second source. Read this, then follow the topics it names.
28
28
  阶梯步骤已内联工具必填参数(`route.ladder[].tools`/`readTools`)与命中 recipe 的 `describe <tool> --example <id>`;据此直接执行——**不要再逐能力 `commands --lane`**,也不要先扫 `commands --json`/全部 `contract`/全部 `describe --full`。
29
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
  1b. **改/删/重命名既有对象前先看影响面(本地规则图索引)**:`omc graph pull --env <env>` 一次拉全平台建本地索引(增量、秒级、带锁与 gap 报告),之后 `omc graph references <service|model|field> <code>` 离线答「谁引用了它」——一调替代逐模型 `rule.get` 枚举(r30 实测影响评估手工枚举 8×rule.get,graph 一调可达);`impact <kind> <code>` 看爆炸半径、`search <词>` 找规则 code、`status` 看索引新鲜度。索引不含规则条件叶子与流程绑定,命中后仍以 `rule.get` 原文为准。无索引时会拒绝并指路 pull。宿主已接 MCP 时用 `omc mcp-graph --path <项目根>`(工具 `omc_graph_explore`/`omc_graph_status`)。
31
+
32
+ **图谱只覆盖单应用时怎么按应用盘点(只读)**:先用
33
+ `omc call app.list` 列出应用,再用
34
+ `omc call model.list --args '{"applicationCode":"<code>"}'` 按应用盘点模型。
35
+ graph 索引 coverage 默认只含配置应用;coverage 未命中不等于应用或模型不存在。
31
36
  1c. **写后端 Java 源码(在线开发)走工作区而不是一条条 call**:`omc programming pull --env <env> --app <appCode>` 把业务规则源码拉进 `.omc-programming/`(git 可管),改文件后 `omc programming status` → `omc programming apply [--publish]`(逐文件走编程面治理);图形化规则转编码用 `omc programming adopt --schema <s> --rule <r>`;验证用 `omc programming test --schema <s> --rule <r>`(fixture 驱动:compile→publish→触发→断言→清理)。**平台运行时只执行已发布版**:只 save 不 publish 等于没改;publish 内部会编译并把规则切 CODING+enable。包路径/类名不得改。
32
37
  2. 写前只需读 `omc-config-draft-review`(人审门)与 `omc-five-piece-flow`(发现→草稿→权限→执行→回读);`omc-business-configuration` 按命中的能力分节读(见 `next` 的 `readSections`),不要整篇预取。
33
38
  3. 环境名以 `omc doctor` 的「环境=」或 `.omc-env` 为准;工具不确定先用默认 `describe`/`--example`,只有单个工具缺形状才用 `--full`;`describe` 带 `exampleHint`/`recipeHint` 时优先 `--example`。