@stalfh233/omc-cli 0.5.3 → 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,8 +1831,14 @@ 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));
@@ -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,
@@ -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;
@@ -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.3",
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.3",
55
+ "@stalfh233/omc-fake-platform": "0.5.4",
56
56
  "commander": "14.0.3",
57
- "omc-core": "0.5.3",
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