@stalfh233/omc-cli 0.5.2 → 0.5.4

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.
@@ -44,6 +44,12 @@ export function stripOutputFlags(argv) {
44
44
  const token = normalized[index];
45
45
  if (token === "--quiet" || token === "--verbose")
46
46
  continue;
47
+ // `--utf8` is consumed at module load (cli.ts sets OMC_ASCII_STDOUT=0 from
48
+ // argv BEFORE any output is produced), so it must be stripped here too or
49
+ // commander rejects it as `unknown option` (r-finding 2026-10-30: the
50
+ // banner and README both taught `--utf8` while every invocation failed).
51
+ if (token === "--utf8")
52
+ continue;
47
53
  if (token === "--out") {
48
54
  index += 1;
49
55
  continue;
package/dist/cli.js CHANGED
@@ -661,7 +661,7 @@ async function cmdArtifactGet(id, transport, options = {}) {
661
661
  if (options.jq !== undefined) {
662
662
  const projected = projectArtifactPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
663
663
  return emitTransportResult(projected.missed
664
- ? { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, projection: { path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }, fetch: artifactFetchHint(id) }
664
+ ? { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, unanswered: true, projection: { path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, hint: "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、数组映射 items[].fields.x、按键选择 rules[code=Create]。available 是该层真实存在的键。" }, fetch: artifactFetchHint(id) }
665
665
  : { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, projection: { path: options.jq, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}) } }, transport);
666
666
  }
667
667
  return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, fetch: artifactFetchHint(id) }, transport);
@@ -685,15 +685,15 @@ async function cmdArtifactGet(id, transport, options = {}) {
685
685
  else
686
686
  missing.push(raw);
687
687
  }
688
- const result = { status: "ok", artifactId: id, sha256: artifact.sha256, keys: wanted, ...(missing.length > 0 ? { missingKeys: missing, availableKeys: record ? Object.keys(record) : [] } : {}), content: projected };
688
+ const result = { status: "ok", artifactId: id, sha256: artifact.sha256, keys: wanted, ...(missing.length > 0 ? { missingKeys: missing, availableKeys: record ? Object.keys(record) : [], unanswered: true, hint: `这些键不在产物顶层:${missing.join("、")}。availableKeys 是真实顶层键;嵌套值用 --jq a.b 或 --extract a.b` } : {}), content: projected };
689
689
  if (Buffer.byteLength(JSON.stringify(result), "utf8") > transport.maxOutputBytes)
690
- return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, projection: options.keys, ...(missing.length > 0 ? { missingKeys: missing, availableKeys: record ? Object.keys(record) : [] } : {}), fetch: artifactFetchHint(id) }, transport);
690
+ return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, projection: options.keys, ...(missing.length > 0 ? { missingKeys: missing, availableKeys: record ? Object.keys(record) : [], unanswered: true } : {}), fetch: artifactFetchHint(id) }, transport);
691
691
  return emitTransportResult(result, transport);
692
692
  }
693
693
  if (options.jq !== undefined) {
694
694
  const projected = projectArtifactPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
695
695
  return emitTransportResult(projected.missed
696
- ? { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, hint: `路径未命中段 "${projected.at}"——available 是该层可用的键/范围;方括号下标(a[0])与点式(a.0)等价;[] 映射数组(items[].fields.x)` }
696
+ ? { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, unanswered: true, hint: `路径未命中段 "${projected.at}"——available 是该层可用的键/范围;方括号下标(a[0])与点式(a.0)等价;[] 映射数组(items[].fields.x);按键选择 rules[code=Create]` }
697
697
  : { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}) }, transport);
698
698
  }
699
699
  // r6-onboarding 2026-10-21: 47 artifact fetches, 0 used a projection; the 10
@@ -1831,15 +1831,21 @@ async function main() {
1831
1831
  intentCommand.command("plan <file>").option("--env <environment>").option("--plan-out <file>").option("--max-output-bytes <bytes>").action(async (file, options) => {
1832
1832
  commandExitCode = await withTransport(options, request, (transport) => cmdIntentPlan(file, options.env, options.planOut, transport));
1833
1833
  });
1834
- intentCommand.command("apply <file>").option("--env <environment>").option("--dry-run").option("--no-verify").option("--confirm-production").option("--max-output-bytes <bytes>").action(async (file, options) => {
1835
- commandExitCode = await withTransport(options, request, (transport) => cmdIntentApply(file, { env: options.env, dryRun: options.dryRun === true, verify: options.verify !== false, confirmProduction: options.confirmProduction === true }, transport));
1834
+ intentCommand.command("apply <file>").option("--env <environment>").option("--dry-run").option("--no-verify").option("--confirm-production").option("--wait <milliseconds>", "写锁冲突时的有界排队毫秒数(同 `omc call --wait`,默认 0=立刻拒绝)").option("--max-output-bytes <bytes>").action(async (file, options) => {
1835
+ const waitRaw = options.wait === undefined ? 0 : Number(options.wait);
1836
+ if (!Number.isSafeInteger(waitRaw) || waitRaw < 0 || waitRaw > 600_000) {
1837
+ console.error("--wait must be an integer number of milliseconds from 0 to 600000");
1838
+ commandExitCode = CLI_EXIT_CODES.invalidInput;
1839
+ return;
1840
+ }
1841
+ commandExitCode = await withTransport(options, request, (transport) => cmdIntentApply(file, { env: options.env, dryRun: options.dryRun === true, verify: options.verify !== false, confirmProduction: options.confirmProduction === true, waitMs: waitRaw }, transport));
1836
1842
  });
1837
1843
  intentCommand.command("readback <file>").option("--env <environment>").option("--max-output-bytes <bytes>").action(async (file, options) => {
1838
1844
  commandExitCode = await withTransport(options, request, (transport) => cmdIntentReadback(file, options.env, transport));
1839
1845
  });
1840
1846
  // ADR-0008: local derived rule-graph index — CLI management face, not a call
1841
1847
  // tool (so it stays out of `commands --json`/coverage by construction).
1842
- const graphCommand = command.command("graph").description("本地派生规则图索引(ADR-0008):pull 全量重建(有界并发)/ references 离线引用 / impact 影响面 / search 规则全文 / status 完整性与新鲜度。候选定位器——不进 baseline/plan/写门禁");
1848
+ const graphCommand = command.command("graph").description("本地派生规则图索引(ADR-0008):pull 全量重建(有界并发)/ references 离线引用 / impact 影响面 / search 规则 code+name 检索(非规则体)/ status 完整性与新鲜度。候选定位器——不进 baseline/plan/写门禁");
1843
1849
  graphCommand.command("pull").option("--env <environment>").option("--environment <environment>").option("--concurrency <n>", "并发 wire 读数(1–32,默认 16):拉取已流水线化,墙钟 ≈ max(模型面,规则面)/并发 × 延迟;过高可能触发平台限流形成缺口").option("--max-output-bytes <bytes>").addHelpText("after", "\n只读远端(model.list → 每模型 rule.list+datarule.list → 每规则 rule.get,统一任务池流水线并发)+ 本地原子重建;不写环境、不占环境写锁。新鲜度随结果回报(complete|partial),查询走 graph references / impact / search。\n").action(async (options) => {
1844
1850
  const parsed = options.concurrency === undefined ? undefined : Number.parseInt(String(options.concurrency), 10);
1845
1851
  commandExitCode = await withTransport(options, request, (transport) => cmdGraphPull(options.environment ?? options.env, parsed, transport));
@@ -1850,12 +1856,24 @@ async function main() {
1850
1856
  graphCommand.command("impact <kind> <code>").option("--max-output-bytes <bytes>").addHelpText("after", "\n影响面(离线):跳1=引用该编码的规则(同 references),跳2=这些规则还读写哪些 service/model/field——改前看爆炸半径。截断上限 50 规则×每类 20 码。\n").action(async (kind, code, options) => {
1851
1857
  commandExitCode = await withTransport(options, request, (transport) => cmdGraphImpact(kind, code, transport));
1852
1858
  });
1853
- graphCommand.command("search <term...>").option("--max-output-bytes <bytes>").addHelpText("after", "\n规则全文检索(code+name,FTS5,末词前缀匹配):找到\"关于 X 的规则\"拿 code,再走 references/impact。中文需给全词或词首(如 报名*)。\n").action(async (term, options) => {
1859
+ graphCommand.command("search <term...>").option("--max-output-bytes <bytes>").addHelpText("after", "\n检索规则的 code+name(FTS5,末词前缀匹配;**不含规则体/配置内容**)。找到\"关于 X 的规则\"拿 code,再走 references/impact。中文必须是全词或词首通配(如 报名* / 增加预算*)——子串不命中:FTS5 把「增加预算」当一个 token,搜「预算」为 0。\n").action(async (term, options) => {
1854
1860
  commandExitCode = await withTransport(options, request, (transport) => cmdGraphSearch(term.join(" "), transport));
1855
1861
  });
1856
1862
  graphCommand.command("status").option("--max-output-bytes <bytes>").action(async (options) => {
1857
1863
  commandExitCode = await withTransport(options, request, (transport) => cmdGraphStatus(transport));
1858
1864
  });
1865
+ // MCP stdio server (host registration: cpdocs installer's omc-graph entry;
1866
+ // top-level verb like `codegraph serve --mcp`, not a graph subcommand).
1867
+ // hidden like the graph lane — a transport, not a user verb. Only JSON-RPC
1868
+ // may reach stdout from here; envelopes/banners never do.
1869
+ command
1870
+ .command("mcp-graph", { hidden: true })
1871
+ .description("以 MCP stdio 服务器运行规则图检索(工具:omc_graph_explore / omc_graph_status)")
1872
+ .option("--path <dir>", "项目根目录(定位 .omc/graph/graph.db;MCP 注册时传绝对路径,不信任宿主 cwd)")
1873
+ .action(async (options) => {
1874
+ const { runMcpGraphServer } = await import("./mcp-graph.js");
1875
+ commandExitCode = await runMcpGraphServer(options.path);
1876
+ });
1859
1877
  // `--jq` expressions the shell split arrive as several tokens; re-join them.
1860
1878
  await command.parseAsync(joinSplitFlagValues(stripOutputFlags(argv), ["--jq"]));
1861
1879
  return commandExitCode;
@@ -585,7 +585,7 @@ export function goalRoute(registry, goal) {
585
585
  readiness: DISCOVERY_AUTHORITY.readiness,
586
586
  firstCommands: [
587
587
  "omc describe field.update --example form-conditional-required",
588
- "omc call field.list --args '{\"schemaCode\":\"<schemaCode\",\"full\":true}'",
588
+ "omc call field.list --args '{\"schemaCode\":\"<schemaCode>\",\"full\":true}'",
589
589
  ],
590
590
  write: true,
591
591
  },
package/dist/graph.js CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { joinConfigRoot } from "./command-transport.js";
5
5
  import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "./json-column.js";
6
6
  import { ENDPOINTS } from "./endpoints.js";
7
- import { flattenTreeRows, resolveApplicationCode, sha256 } from "./support.js";
7
+ import { applicationResolutionOf, flattenTreeRows, resolveApplicationScope, sha256 } from "./support.js";
8
8
  import { wireCall, wireRows } from "./wire.js";
9
9
  /**
10
10
  * `node:sqlite` must stay OUT of the CLI's startup module graph: a static
@@ -177,7 +177,8 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
177
177
  // returns EMPTY data on a real platform (works only against the fake face)
178
178
  // — using it here made the first live pull enumerate zero models and (pre-
179
179
  // zero-guard) wedge the scheduler.
180
- const applicationCode = await resolveApplicationCode(session, undefined).catch(() => "");
180
+ const scope = await resolveApplicationScope(session, undefined).catch(() => undefined);
181
+ const applicationCode = scope?.code ?? "";
181
182
  const schemaCodes = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data))
182
183
  .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "")
183
184
  .map((row) => String(row.code))
@@ -368,7 +369,8 @@ export async function pullRuleGraph(session, concurrency = DEFAULT_PULL_CONCURRE
368
369
  scope: "businessrule",
369
370
  identityFingerprint: identity,
370
371
  environmentName: session.environmentName,
371
- applicationCode: session.profile.applicationCode,
372
+ applicationCode,
373
+ ...(scope !== undefined ? { applicationResolution: applicationResolutionOf(scope) } : {}),
372
374
  pulledAtIso,
373
375
  },
374
376
  rules,
@@ -644,7 +646,14 @@ export function searchGraphRules(term, limit = 20) {
644
646
  rules: matches,
645
647
  index: summary,
646
648
  ...(matches.length === 0
647
- ? { note: tokens.length === 0 ? "检索词为空" : "没有命中规则(code+name 全文;引用检索用 references/impact <kind> <code>)" }
649
+ ? {
650
+ note: tokens.length === 0
651
+ ? "检索词为空"
652
+ // 实测摩擦(2026-10-30,zlt测试):中文子串 0 命中后没有下一步提示,
653
+ // 调用方在「换个词再试」里打转。把两个出口都写进 note:CJK 词首
654
+ // 通配 + 找引用走 references/impact。
655
+ : "没有命中规则。索引只覆盖规则的 code+name(不含规则体);且只匹配**词首**——FTS5 把「增加预算」当一个词,「预算」匹配不到它。请给全词(增加预算)、词首(增加 或 增加*),或英文 code 前缀。找\"谁引用了 X\"请走 references/impact <kind> <code>。",
656
+ }
648
657
  : {}),
649
658
  ...(gapped ? { gapWarning: `索引有 ${summary.gaps.count} 个扫描缺口;命中列表可能不完整` } : {}),
650
659
  };
@@ -453,7 +453,17 @@ export async function applyIntent(planPath, options, ports) {
453
453
  }
454
454
  // The whole apply holds the same cross-process write lock `omc call`
455
455
  // acquires per write — one writer per environment across every face.
456
- const lock = await acquireWriteLock(plan.environment);
456
+ // `--wait` mirrors `omc call --wait`: bound the retry inside the CLI instead
457
+ // of forcing the caller to spend a round trip per conflict. Default stays 0
458
+ // (fail fast, structured refusal) — the opt-in is what makes it safe for
459
+ // non-replayable batches.
460
+ const waitMs = options.waitMs ?? 0;
461
+ const deadline = Date.now() + waitMs;
462
+ let lock = await acquireWriteLock(plan.environment);
463
+ while (!lock.held && Date.now() < deadline) {
464
+ await new Promise((resolve) => setTimeout(resolve, Math.min(200, Math.max(0, deadline - Date.now()))));
465
+ lock = await acquireWriteLock(plan.environment);
466
+ }
457
467
  if (!lock.held) {
458
468
  const conflict = {
459
469
  status: "refused",
@@ -4,7 +4,7 @@ import { capabilityArg, environmentArg } from "../args.js";
4
4
  import { governedRead } from "../read.js";
5
5
  import { writeTool } from "../write.js";
6
6
  import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "../json-column.js";
7
- import { flattenTreeRows, resolveApplicationCode } from "../support.js";
7
+ import { applicationResolutionOf, applicationScopeHint, flattenTreeRows, resolveApplicationScope } from "../support.js";
8
8
  import { executeRuleSaveWrite, ruleNodeGraphArg } from "../rule-support.js";
9
9
  import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
10
10
  import { resolveRuleRowId } from "../identity.js";
@@ -139,8 +139,11 @@ export function registerBusinessRuleLane(tools) {
139
139
  // Model enumeration mirrors `model.list`: the bare modelList endpoint
140
140
  // returns EMPTY rows on a real platform (live 2026-10-29: a full scan
141
141
  // "found" 0 models → false zero-hit), so walk the application tree.
142
+ // The implicit scope is echoed: a zero-hit scan over 1-of-N applications
143
+ // must not read as "nothing references this" tenant-wide.
144
+ const scope = args.schemaCodes === undefined ? await resolveApplicationScope(session, undefined) : undefined;
142
145
  const scoped = args.schemaCodes === undefined
143
- ? flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: await resolveApplicationCode(session, undefined).catch(() => "") } })).data))
146
+ ? flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: scope.code } })).data))
144
147
  .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code !== "")
145
148
  .map((row) => String(row.code))
146
149
  : (Array.isArray(args.schemaCodes) ? args.schemaCodes : [args.schemaCodes]);
@@ -209,8 +212,12 @@ export function registerBusinessRuleLane(tools) {
209
212
  code: wanted,
210
213
  scanned: { models: scoped.length, rules: scannedRules, ...(parseFailures > 0 ? { parseFailures } : {}) },
211
214
  matches,
215
+ ...(scope !== undefined ? { applicationResolution: applicationResolutionOf(scope) } : {}),
212
216
  ...(matches.length === 0
213
- ? { note: "零命中=全平台(或指定 schemaCodes 范围内)没有规则引用它。范围:service→methodMapping.serviceCode;model→targetObjectCode/methodMapping.schemaCode;field→dataActions.targetDataItem/映射 bizCode(不含条件叶子与 datarule,第一版边界)" }
217
+ ? {
218
+ note: "零命中=全平台(或指定 schemaCodes 范围内)没有规则引用它。范围:service→methodMapping.serviceCode;model→targetObjectCode/methodMapping.schemaCode;field→dataActions.targetDataItem/映射 bizCode(不含条件叶子与 datarule,第一版边界)",
219
+ ...(scope !== undefined ? { hint: applicationScopeHint(scope, "引用") } : {}),
220
+ }
214
221
  : {}),
215
222
  };
216
223
  }, { target: `rule.references:${args.kind}:${args.code}` }),
@@ -52,7 +52,7 @@ export const DICTIONARY_LANE_CONTRACT = Object.freeze({
52
52
  "dictionary.list": Object.freeze({ scope: "dictionary list", description: "分页读取数据字典(data_dictionary/searchDictionary),返回字典元数据。" }),
53
53
  "dictionary.get": Object.freeze({ scope: "dictionary read", description: "按 id 或 code 读取数据字典及其元数据。" }),
54
54
  "dictionary.records": Object.freeze({ scope: "dictionary records read", description: "读取字典记录;支持按 id/code、启用状态和 parentId 读取,保留树形 children。" }),
55
- "dictionary.save": Object.freeze({ scope: "dictionary save", description: "创建或更新数据字典及记录(data_dictionary/saveDictionary);KEY_VALUE=0,TREE=1,写入前需审批。" }),
55
+ "dictionary.save": Object.freeze({ scope: "dictionary save", description: "创建或更新数据字典及记录(data_dictionary/saveDictionary);KEY_VALUE=0,TREE=1,写入前需审批。records 是增量 upsert:传 [] 不会清空已有记录,平台也没有字典/记录删除端点——停用请用 dictionary.status,清理需维护者数据库操作。" }),
56
56
  "dictionary.status": Object.freeze({ scope: "dictionary status", description: "启用或停用字典或字典记录;停用字典时运行时不会返回选项。" }),
57
57
  "dictionary.class": Object.freeze({ scope: "dictionary classification", description: "读取、新建、删除字典分类并维护分类关联字典。" }),
58
58
  "dictionary.usage": Object.freeze({ scope: "dictionary record usage", description: "批量设置字典记录 initialUsed 使用状态;记录新增/更新仍随 dictionary.save.records。" }),
@@ -278,10 +278,39 @@ export function registerDictionaryLane(tools) {
278
278
  writeTool(tools, "dictionary.class.delete", LANE, TOOLS["dictionary.class.delete"].scope, TOOLS["dictionary.class.delete"].description, {
279
279
  ids: z.array(z.string().min(1)).min(1),
280
280
  }, async (session, args) => {
281
+ // The platform's deleteDictionaryClass(Batch) answers errcode 0 for ANY id —
282
+ // a dictionary id, a typo, anything — while deleting nothing. Writing
283
+ // `absent: true` unconditionally turned that into a silent false success
284
+ // (2026-10-30 probe: passing a dictionary id reported success and left the
285
+ // object in place). Verify the inputs are class ids BEFORE the write and
286
+ // derive absence from a post-delete read.
287
+ const classIds = async () => {
288
+ const listed = (await wireCall(session, { path: ENDPOINTS.dictionaryClasses, query: {} })).data;
289
+ return new Set((Array.isArray(listed) ? listed : [])
290
+ .map((entry) => (typeof entry.id === "string" ? entry.id : ""))
291
+ .filter((id) => id !== ""));
292
+ };
293
+ const before = await classIds();
294
+ const unmatched = args.ids.filter((id) => !before.has(id));
295
+ if (unmatched.length > 0) {
296
+ throw new ToolRefusalError({
297
+ status: "refused",
298
+ blocker: "dictionary-class-absent",
299
+ ids: args.ids,
300
+ unmatched,
301
+ candidates: [...before].slice(0, 20),
302
+ message: `${unmatched.join("、")} 不是字典分类 id(删前分类列表里没有它)。平台 deleteDictionaryClass 对任意 id 都回 errcode 0 却不删任何行——传字典 id 会假成功。现有分类候选:${[...before].slice(0, 20).join("、") || "(当前没有分类)"}。另:字典本体与字典项平台无删除端点,只能 dictionary.status 停用。`,
303
+ });
304
+ }
281
305
  const path = args.ids.length === 1 ? ENDPOINTS.dictionaryClassDelete : ENDPOINTS.dictionaryClassDeleteBatch;
282
306
  const body = args.ids.length === 1 ? { id: args.ids[0] } : { ids: args.ids };
283
307
  const performed = await wireCall(session, { path, method: "POST", body });
284
- return { performed: { errcode: performed.errcode, ids: args.ids }, readback: { absent: true, ids: args.ids } };
308
+ const after = await classIds();
309
+ const stillPresent = args.ids.filter((id) => after.has(id));
310
+ return {
311
+ performed: { errcode: performed.errcode, ids: args.ids },
312
+ readback: { absent: stillPresent.length === 0, ids: args.ids, ...(stillPresent.length > 0 ? { stillPresent } : {}) },
313
+ };
285
314
  }, undefined, undefined, dictionaryRollbackSpec("dictionary.class.delete"));
286
315
  }
287
316
  //# sourceMappingURL=dictionary.js.map
@@ -9,7 +9,7 @@ import { writeSummary } from "../human-summary.js";
9
9
  import { capabilityArg, environmentArg } from "../args.js";
10
10
  import { governedRead, withoutReadbackCache } from "../read.js";
11
11
  import { parseToolInput } from "../tool-types.js";
12
- import { flattenTreeRows, resolveApplicationCode } from "../support.js";
12
+ import { applicationResolutionOf, applicationScopeHint, flattenTreeRows, resolveApplicationCode, resolveApplicationScope } from "../support.js";
13
13
  import { writeTool } from "../write.js";
14
14
  import { registerPackageTool } from "../package-tool.js";
15
15
  import { defineTool, ToolInputError, ToolRefusalError } from "../tool-types.js";
@@ -196,7 +196,8 @@ export function registerModelLane(tools) {
196
196
  capability: capabilityArg,
197
197
  },
198
198
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
199
- const applicationCode = await resolveApplicationCode(session, args.applicationCode);
199
+ const scope = await resolveApplicationScope(session, args.applicationCode);
200
+ const applicationCode = scope.code;
200
201
  const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
201
202
  const all = tree
202
203
  .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code)
@@ -209,7 +210,19 @@ export function registerModelLane(tools) {
209
210
  // `filterApplied` disambiguates an empty result: it distinguishes "the
210
211
  // filter ran and matched nothing" from "the filter was not applied"
211
212
  // (dogfood R1: count:0 alone gave no signal).
212
- return { applicationCode, count: models.length, totalBeforeFilter: all.length, filterApplied: needle !== undefined && needle !== "", ...(needle ? { filter: needle } : {}), models };
213
+ // `applicationResolution` says WHY this application was scanned: the
214
+ // implicit fallback to the tenant's first application narrows the scan to
215
+ // 1-of-N, so `count: 0` must never read as tenant-wide absence.
216
+ return {
217
+ applicationCode,
218
+ applicationResolution: applicationResolutionOf(scope),
219
+ count: models.length,
220
+ totalBeforeFilter: all.length,
221
+ filterApplied: needle !== undefined && needle !== "",
222
+ ...(needle ? { filter: needle } : {}),
223
+ models,
224
+ ...(models.length === 0 ? { hint: applicationScopeHint(scope, "模型") } : {}),
225
+ };
213
226
  }, { target: `model.list:${args.applicationCode ?? ""}:${args.filter ?? ""}` }),
214
227
  }));
215
228
  const siblings = tools;
@@ -0,0 +1,222 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { GraphIndexAbsentError, graphDbPath, impactGraph, queryGraphReferences, readGraphStatus, searchGraphRules, } from "./graph.js";
6
+ /**
7
+ * Stdio MCP server for the local rule-graph index (ADR-0008).
8
+ *
9
+ * Wire format is the MCP standard — newline-delimited JSON-RPC 2.0 — hand
10
+ * rolled on purpose: the precedent in this stack (CodeGraph) ships its own
11
+ * transport too, and omc-cli keeps a single-digit dependency budget. The
12
+ * protocol surface needed by Claude Code / Codex / WorkBuddy is small:
13
+ * initialize → tools/list → tools/call (+ping), everything else is -32601.
14
+ *
15
+ * Why a server at all (friction evidence, 2026-10-30 zlt测试 session):
16
+ * 1. CLI return shapes had to be guessed (`rules` mis-read as `matches`);
17
+ * MCP tools are self-describing via input/output examples in descriptions.
18
+ * 2. CJK terms passed through a Windows console got mangled — a process-bound
19
+ * JSON pipe is UTF-8 end to end.
20
+ * 3. Iterative explore→refine→explore paid a full CLI boot per question.
21
+ *
22
+ * Read-only by construction: the handlers only query the local SQLite index;
23
+ * `pull` deliberately stays a CLI verb (it reads the platform for ~a minute).
24
+ */
25
+ const PROTOCOL_VERSION = "2024-11-05";
26
+ const ownVersion = () => {
27
+ try {
28
+ return createRequire(import.meta.url)("../package.json").version;
29
+ }
30
+ catch {
31
+ return "0.0.0";
32
+ }
33
+ };
34
+ const SERVER_INFO = { name: "omc-graph", version: ownVersion() };
35
+ const KINDS = ["field", "model", "service"];
36
+ const MATCH_CAP = 50;
37
+ const ERROR_CODES = { parse: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602, internal: -32603 };
38
+ function respond(id, result) {
39
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, result })}\n`);
40
+ }
41
+ function respondError(id, code, message) {
42
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error: { code, message } })}\n`);
43
+ }
44
+ const INSTRUCTIONS_INDEXED = [
45
+ "规则/配置图检索(本地索引,先于逐平台扫描使用):",
46
+ "- 改名/删除/重构一个 字段(field)/模型(model)/服务(service) 前,先用 omc_graph_explore 查引用与影响面;查\"谁引用了 X\"也走它。",
47
+ "- 索引只覆盖:业务规则(methodMapping/targetObjectCode)、数据规则(propertyCodes 与公式 {字段})、视图(列/排序/条件)。**不含**规则条件叶子与流程绑定。",
48
+ "- search 语义:只匹配规则 code+name 的**词首**(中文子串不命中:搜「预算」匹配不到「增加预算」)。",
49
+ "答案带 freshness/pulledAtIso——索引是快照,平台侧改动后需 CLI 重建:omc graph pull --env <env>(只读远端,约 1 分钟)。",
50
+ "索引绑定单一环境(environmentName);查别的环境先重新 pull。",
51
+ ].join("\n");
52
+ const INSTRUCTIONS_NO_INDEX = [
53
+ "规则/配置图检索(omc-graph):当前项目还没有本地索引(<项目>/.omc/graph/graph.db 不存在),工具暂时无法回答。",
54
+ "先请用户执行 CLI:omc graph pull --env <环境名>(只读远端,约 1 分钟;环境名以 omc doctor 的「环境=」或 .omc-env 为准),完成后再来调用。",
55
+ "不要在无索引状态下反复重试本工具。",
56
+ ].join("\n");
57
+ const EXPLORE_TOOL = {
58
+ name: "omc_graph_explore",
59
+ description: [
60
+ "一次查全一个目标的引用与影响面(本地索引,毫秒级)。",
61
+ "输入 query=字段/模型/服务的 code(或规则检索词);kind 可选 field|model|service,不给则三种都查。",
62
+ "返回 {index(freshness), references:{kind:[{schemaCode,ruleCode,nodeCode,nodeType,via}]}, impact:{kind:totals}, search:{rules}}。",
63
+ "改/删/重命名任何字段、模型、服务之前必查;找\"关于 X 的规则\"拿 code 也走这里。",
64
+ "索引不含规则条件叶子与流程绑定;freshness=partial 时命中可能不完整(答案里有 gapWarning)。",
65
+ ].join(" "),
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ query: { type: "string", description: "字段/模型/服务的 code,或规则检索词(code+name 词首匹配)" },
70
+ kind: { type: "string", enum: [...KINDS], description: "限定一种引用种类;省略则 field/model/service 全查" },
71
+ env: { type: "string", description: "期望的环境名;与索引所属环境不一致时会拒绝(防跨环境误答)" },
72
+ },
73
+ required: ["query"],
74
+ },
75
+ };
76
+ const STATUS_TOOL = {
77
+ name: "omc_graph_status",
78
+ description: "索引完整性与新鲜度:environmentName、freshness(complete|partial)、模型/规则计数、pulledAtIso、gaps。回答前先看一眼,索引缺失会给出 pull 指引。",
79
+ inputSchema: { type: "object", properties: {} },
80
+ };
81
+ export function graphToolDefinitions() {
82
+ return [EXPLORE_TOOL, STATUS_TOOL];
83
+ }
84
+ function toolResult(payload, isError = false) {
85
+ return { content: [{ type: "text", text: JSON.stringify(payload) }], ...(isError ? { isError: true } : {}) };
86
+ }
87
+ function toolError(message) {
88
+ return toolResult({ status: "error", message }, true);
89
+ }
90
+ function cap(rows) {
91
+ return { rows: rows.slice(0, MATCH_CAP), truncated: rows.length > MATCH_CAP };
92
+ }
93
+ /** Shared handler core — exported so tests drive it without a stdio pair. */
94
+ export async function executeGraphTool(name, rawArgs) {
95
+ const args = (rawArgs ?? {});
96
+ try {
97
+ if (name === STATUS_TOOL.name) {
98
+ const status = readGraphStatus();
99
+ return toolResult({ status: "ok", ...status });
100
+ }
101
+ if (name === EXPLORE_TOOL.name) {
102
+ const query = typeof args.query === "string" ? args.query.trim() : "";
103
+ if (!query)
104
+ return toolError("query 不能为空");
105
+ const kind = args.kind === undefined ? undefined : String(args.kind);
106
+ if (kind !== undefined && !KINDS.includes(kind))
107
+ return toolError(`kind 必须是 ${KINDS.join("|")} 之一`);
108
+ const status = readGraphStatus();
109
+ const index = status.index;
110
+ if (typeof args.env === "string" && args.env.trim() && args.env.trim() !== index.environmentName) {
111
+ return toolError(`索引属于环境「${index.environmentName}」;要查「${args.env.trim()}」请先 omc graph pull --env ${args.env.trim()} 重建索引`);
112
+ }
113
+ const kinds = kind === undefined ? [...KINDS] : [kind];
114
+ const references = {};
115
+ const impact = {};
116
+ for (const one of kinds) {
117
+ const ref = queryGraphReferences(one, query);
118
+ const capped = cap(ref.matches);
119
+ references[one] = { count: ref.matches.length, ...capped, ...(ref.gapWarning ? { gapWarning: ref.gapWarning } : {}) };
120
+ if (ref.matches.length > 0 || kind !== undefined) {
121
+ const imp = impactGraph(one, query);
122
+ impact[one] = {
123
+ totals: imp.totals,
124
+ rules: cap(imp.rules),
125
+ datarules: cap(imp.datarules),
126
+ listviews: cap(imp.listviews),
127
+ };
128
+ }
129
+ }
130
+ const search = searchGraphRules(query);
131
+ return toolResult({
132
+ status: "ok",
133
+ query,
134
+ ...(kind !== undefined ? { kind } : {}),
135
+ index,
136
+ references,
137
+ impact,
138
+ search: { count: search.rules.length, rules: search.rules },
139
+ ...(search.note ? { searchNote: search.note } : {}),
140
+ note: "索引不含规则条件叶子与流程绑定;改这些面仍需平台侧核查",
141
+ });
142
+ }
143
+ return toolError(`未知工具:${name}`);
144
+ }
145
+ catch (error) {
146
+ if (error instanceof GraphIndexAbsentError) {
147
+ return toolError(`本地索引不存在(${graphDbPath()})。先执行:omc graph pull --env <环境名>(只读远端,约 1 分钟),完成后再调用`);
148
+ }
149
+ return toolError(error instanceof Error ? error.message : String(error));
150
+ }
151
+ }
152
+ async function handleMessage(message) {
153
+ const id = message.id;
154
+ if (id === undefined || id === null)
155
+ return; // notification — no response
156
+ if (message.jsonrpc !== "2.0" || typeof message.method !== "string") {
157
+ respondError(id, ERROR_CODES.invalidRequest, "invalid JSON-RPC 2.0 request");
158
+ return;
159
+ }
160
+ const { method } = message;
161
+ if (method === "initialize") {
162
+ const params = (message.params ?? {});
163
+ const requested = typeof params.protocolVersion === "string" && params.protocolVersion ? params.protocolVersion : PROTOCOL_VERSION;
164
+ respond(id, {
165
+ protocolVersion: requested,
166
+ capabilities: { tools: {} },
167
+ serverInfo: SERVER_INFO,
168
+ instructions: existsSync(graphDbPath()) ? INSTRUCTIONS_INDEXED : INSTRUCTIONS_NO_INDEX,
169
+ });
170
+ return;
171
+ }
172
+ if (method === "ping") {
173
+ respond(id, {});
174
+ return;
175
+ }
176
+ if (method === "tools/list") {
177
+ respond(id, { tools: graphToolDefinitions() });
178
+ return;
179
+ }
180
+ if (method === "tools/call") {
181
+ const params = (message.params ?? {});
182
+ if (typeof params.name !== "string") {
183
+ respondError(id, ERROR_CODES.invalidParams, "params.name must be a string");
184
+ return;
185
+ }
186
+ respond(id, await executeGraphTool(params.name, params.arguments));
187
+ return;
188
+ }
189
+ respondError(id, ERROR_CODES.methodNotFound, `method not found: ${method}`);
190
+ }
191
+ /**
192
+ * Run the stdio server. Resolves when stdin closes (the host went away).
193
+ * `projectRoot` (from --path) pins the index location exactly like the
194
+ * codegraph registration does — never trust the host's cwd.
195
+ */
196
+ export async function runMcpGraphServer(projectRoot) {
197
+ if (projectRoot)
198
+ process.chdir(resolve(projectRoot));
199
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
200
+ lines.on("line", (line) => {
201
+ const text = line.trim();
202
+ if (!text)
203
+ return;
204
+ let parsed;
205
+ try {
206
+ parsed = JSON.parse(text);
207
+ }
208
+ catch {
209
+ respondError(null, ERROR_CODES.parse, "parse error");
210
+ return;
211
+ }
212
+ void handleMessage(parsed).catch((error) => {
213
+ respondError(idOf(parsed), ERROR_CODES.internal, error instanceof Error ? error.message : String(error));
214
+ });
215
+ });
216
+ await new Promise((resolve) => lines.on("close", () => resolve()));
217
+ return 0;
218
+ }
219
+ function idOf(message) {
220
+ return message.id ?? null;
221
+ }
222
+ //# sourceMappingURL=mcp-graph.js.map
@@ -96,6 +96,8 @@ function argvValues(args, name) {
96
96
  }
97
97
  return values;
98
98
  }
99
+ /** Shared wording for every projection miss, so all three faces teach the same syntax. */
100
+ const PATH_MISS_HINT = "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、数组映射 items[].fields.x、按键选择 rules[code=Create](返回第一个匹配项)。available 是该层真实存在的键。";
99
101
  /**
100
102
  * `call --extract/--extract-keys`: resolve paths inside the result's own
101
103
  * artifact (or inside the result object when the tool returned no artifact).
@@ -153,16 +155,21 @@ export function projectResultExtract(result, paths, keysOnly) {
153
155
  if (path.includes("[]")) {
154
156
  const fanout = projectJsonPathFanout(source, path);
155
157
  return fanout.missed
156
- ? { path, content: null, pathMiss: true, missedAt: fanout.at, available: fanout.available, missedEntries: fanout.missedEntries }
157
- : { path, content: fanout.values, mapped: fanout.values.length, ...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries } : {}) };
158
+ ? { path, content: null, pathMiss: true, missedAt: fanout.at, available: fanout.available, missedEntries: fanout.missedEntries, hint: PATH_MISS_HINT }
159
+ : { path, content: fanout.values, mapped: fanout.values.length, ...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries, hint: `有 ${fanout.missedEntries} 条记录在该路径下缺失,values 只含命中项` } : {}) };
158
160
  }
159
161
  const projected = projectJsonPath(source, path);
160
162
  return projected.missed
161
- ? { path, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }
163
+ ? { path, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, hint: PATH_MISS_HINT }
162
164
  : { path, content: projected.value ?? null };
163
165
  });
164
166
  if (paths.length === 1)
165
167
  out.content = out.extract[0]?.content ?? null;
168
+ // A projection that missed is not an answer. `status` stays `ok` (adding a
169
+ // new status value would fall through classifyResult to error/exit 15 and
170
+ // change the exit contract), but the envelope now says so out loud.
171
+ if (out.extract.some((entry) => entry.pathMiss === true))
172
+ out.unanswered = true;
166
173
  }
167
174
  return out;
168
175
  }
package/dist/support.js CHANGED
@@ -71,16 +71,52 @@ export function refusalFromCoverage(error) {
71
71
  * callers may operate across applications.
72
72
  */
73
73
  export async function resolveApplicationCode(session, requested) {
74
- if (typeof requested === "string" && requested.trim())
75
- return requested.trim();
76
- if (session.profile.applicationCode && session.profile.applicationCode.trim())
77
- return session.profile.applicationCode;
74
+ return (await resolveApplicationScope(session, requested)).code;
75
+ }
76
+ /**
77
+ * `resolveApplicationCode` with provenance. The fallback to the tenant's first
78
+ * application is legitimate (it is the documented chain) but it silently
79
+ * narrows a scan to 1-of-N applications — a `count: 0` from such a call reads
80
+ * as "does not exist" when it really means "not in this application"
81
+ * (2026-10-30 live: a 97-application tenant answered "no purchase model").
82
+ * Callers that enumerate must echo the source so a zero cannot be misread.
83
+ */
84
+ export async function resolveApplicationScope(session, requested) {
85
+ if (typeof requested === "string" && requested.trim()) {
86
+ const code = requested.trim();
87
+ return { code, source: "explicit", requested: code };
88
+ }
89
+ if (session.profile.applicationCode && session.profile.applicationCode.trim()) {
90
+ const code = session.profile.applicationCode.trim();
91
+ return { code, source: "configured", requested: code };
92
+ }
78
93
  const apps = wireRows((await wireCall(session, { path: ENDPOINTS.applicationList, query: {} })).data);
79
- const first = apps.find((row) => typeof (row.appCode ?? row.code) === "string");
80
- const code = first ? String(first.appCode ?? first.code) : "";
81
- if (!code)
94
+ const codes = apps
95
+ .map((row) => row.appCode ?? row.code)
96
+ .filter((code) => typeof code === "string" && code !== "");
97
+ if (codes.length === 0)
82
98
  throw new WireError("business", "no application found for this environment", 302001);
83
- return code;
99
+ return { code: codes[0], source: "first-tenant-application", requested: null, applicationCount: codes.length };
100
+ }
101
+ /**
102
+ * The echo payload for a scan-style read: `source` says why this application,
103
+ * `applicationCount` says how many the tenant has (fallback case only), so a
104
+ * zero result can never be mistaken for tenant-wide absence.
105
+ */
106
+ export function applicationResolutionOf(scope) {
107
+ return {
108
+ code: scope.code,
109
+ source: scope.source,
110
+ ...(scope.requested !== null ? { requested: scope.requested } : {}),
111
+ ...(scope.applicationCount !== undefined ? { applicationCount: scope.applicationCount } : {}),
112
+ };
113
+ }
114
+ /** One-line nudge appended to a zero-result scan read; names the real scope of the answer. */
115
+ export function applicationScopeHint(scope, noun) {
116
+ if (scope.source === "first-tenant-application" && (scope.applicationCount ?? 1) > 1) {
117
+ return `本次只扫描了租户首个应用 ${scope.code}(租户共 ${scope.applicationCount} 个应用)——零命中不代表其他应用没有${noun};先 app.list 取 applicationCode 再按应用重查。`;
118
+ }
119
+ return `本次只扫描了应用 ${scope.code}(source=${scope.source})——零命中只代表该应用没有${noun};跨应用请先 app.list 取 applicationCode。`;
84
120
  }
85
121
  export function flattenTreeRows(rows) {
86
122
  const flat = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@stalfh233/omc-fake-platform": "0.5.2",
55
+ "@stalfh233/omc-fake-platform": "0.5.4",
56
56
  "commander": "14.0.3",
57
- "omc-core": "0.5.2",
57
+ "omc-core": "0.5.4",
58
58
  "proper-lockfile": "4.1.2",
59
59
  "write-file-atomic": "6.0.0",
60
60
  "zod": "^3.23.8",
@@ -108,8 +108,10 @@ approved draft, and decide with the human. A second write may duplicate.
108
108
  3. Never blind-retry — that risks a second write.
109
109
 
110
110
  `write-lock-held` — another write holds the environment lock; `owner` and
111
- `recovery` are included. Wait for it to clear (it auto-recovers when the holding
112
- process exits), then retry. This guards concurrency, not approval.
111
+ `recovery` are included. Retry with `--wait <ms>` (`omc call <tool> --wait 5000`,
112
+ `omc intent apply ... --wait 5000`): the CLI does the bounded queueing in-process,
113
+ so it costs no extra round trip. It also auto-recovers when the holding process
114
+ exits. This guards concurrency, not approval.
113
115
 
114
116
  ## 8. Runtime / gateway errors (not tool refusals, same discipline)
115
117
 
@@ -31,7 +31,7 @@ be treated as a second source. Read this, then follow the topics it names.
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`。
33
33
  5. 键名按对象不同:模型 `schemaCode`、默认表单 `sheetCode`(可省略,默认 == 模型 code)、流程 `workflowCode`/`code`——报错会提示同义键;字段族名认不出会给「最接近的族」。
34
- 6. **写操作串行**:同一环境不要并发写(含多个 `omc call` 并行进程——环境写锁互斥,r31 实证并行 field.create 2/3 被拒);多个写合并为一条 `omc script` 逐行跑(进程内自动串行);`write-lock-held` 是瞬态,等几秒重试一次即可。
34
+ 6. **写操作串行**:同一环境不要并发写(含多个 `omc call` 并行进程——环境写锁互斥,r31 实证并行 field.create 2/3 被拒);多个写合并为一条 `omc script` 逐行跑(进程内自动串行);`write-lock-held` 是瞬态:**优先在发起时就排队**——`omc call <tool> --wait 5000`(或 `omc intent apply ... --wait 5000`)让 CLI 内部有界重试,不必自己再花一轮。
35
35
  7. **单个对象优先高阶编排工具**:模型+字段用 `configure_model`(一次含全部字段),表单用 `configure_form`,列表用 `listview.configure`(**默认视图已存在,省略 code 即配置它,不要 `listview.create`**)。原子工具留给单点修改。
36
36
  8. 拿到 `detailArtifactId` 先 `artifact get <id> --summary` 看结构,再 `--keys/--jq` 取所需;**读面要某一列时直接 `omc call <tool> … --jq 'items[].fields.xxx'`(`[]` 映射数组,一次调用拿整列;裸 `--jq` 列键),不要再落 artifact 二次取**;`field.list`/`workflow.*` 默认面已给清单与节点摘要;`workflow.get_draft` 默认面已含 `edges`,改流程只需传 `activities`+`routes`。**注意**:`form.get --full`、`workflow.get_draft` 的 `full:true` 大结果返回的是 `{length, sha256}` 摘要桩——真身走 `artifact get <id>`,勿把桩误判为「子表/控件没配」(用户实测陷阱)。
37
37
  9. **Windows/PowerShell 乱码**:CLI 输出是 UTF-8,PowerShell 管道默认按 GBK(936) 解码会全屏乱码——根因在终端不在 CLI。消费端先 `chcp 65001` + `[Console]::OutputEncoding=[Text.Encoding]::UTF8` 再读 CLI 输出。
@@ -110,7 +110,7 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
110
110
 
111
111
  ## 写调用纪律
112
112
 
113
- - **同一 `<env>` 的写调用必须串行发起**:`write-lock-held` 不只在"并行重放失败请求"时出现,也会在**并行发起两个无关写**时触发(实测:并行两条 `dictionary.save`、并行 `field.update`+`field.publish` 各失败一次)。读可并行,写不要。
113
+ - **同一 `<env>` 的写调用必须串行发起**:`write-lock-held` 不只在"并行重放失败请求"时出现,也会在**并行发起两个无关写**时触发(实测:并行两条 `dictionary.save`、并行 `field.update`+`field.publish` 各失败一次)。读可并行,写不要。确实要并发进程时用 `--wait <ms>`(`omc call` / `omc intent apply` 都支持)让 CLI 内部排队,别把重试循环写在 shell 里。
114
114
 
115
115
  ## 审批提交的真实形状(r18b 2026-10-26 真机,1.1.25)
116
116