@stalfh233/omc-cli 0.6.3 → 0.6.5

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.
package/README.md CHANGED
@@ -336,7 +336,7 @@ npm publish --workspace packages/omc-cli
336
336
 
337
337
  ### 输出编码
338
338
 
339
- stdout 是机器解析的 JSON,编码按消费方选择:**终端**(`isTTY`)写原始 UTF-8(Windows 上 Node 走 `WriteConsoleW`,中文正常显示);**管道/重定向/捕获**写 ASCII 安全 JSON(非 ASCII 转义成 `\uXXXX`,任何代码页都能正确解析,等价于 Python `json.dumps(ensure_ascii=True)`)。Linux/macOS 始终原始 UTF-8;`--out` 文件、artifact、stderr、CloudPivot 请求体始终原始 UTF-8。强制用 `OMC_ASCII_STDOUT=1|0`。
339
+ stdout 是机器解析的 JSON,编码按消费方选择:**终端**(`isTTY`)写原始 UTF-8(Windows 上 Node 走 `WriteConsoleW`,中文正常显示);**管道/重定向/捕获**写 ASCII 安全 JSON(非 ASCII 转义成 `\uXXXX`,任何代码页都能正确解析,等价于 Python `json.dumps(ensure_ascii=True)`)。Linux/macOS 始终原始 UTF-8;`--out` 文件、artifact、CloudPivot 请求体始终原始 UTF-8;stderr 诊断默认跟随 stdout 的代码页判定(`\uXXXX`),加 `--utf8`(= `OMC_HUMAN_UTF8=1`)只关闭**人类诊断文本**的转义、**不影响** stdout 信封(GitLab #17 T2:让 `--utf8` 关信封转义会在 cp936 管道下把 JSON 结构解码坏)。强制用 `OMC_ASCII_STDOUT=1|0`。
340
340
 
341
341
  ### 版本策略
342
342
 
package/dist/call.js CHANGED
@@ -7,6 +7,7 @@ import { redact } from "./redact.js";
7
7
  import { assertSafeInputPath } from "./artifacts.js";
8
8
  import { parseToolInput, ToolInputError } from "./tool-types.js";
9
9
  import { absencePointer } from "./capability-absences.js";
10
+ import { closestMatches } from "./closest-match.js";
10
11
  import { attachRefusalRecovery } from "./refusal-guidance.js";
11
12
  export const CALL_EXIT_CODES = { ok: 0, failed: 1, unknownTool: 2, invalidInput: 10 };
12
13
  export const CLI_ARGS_MAX_BYTES = 1024 * 1024;
@@ -65,6 +66,11 @@ export async function runToolCall(toolName, options = {}) {
65
66
  const names = registry.map((definition) => definition.name);
66
67
  const segment = toolName.split(".")[0] ?? toolName;
67
68
  const candidates = names.filter((name) => name === segment || name.startsWith(`${segment}.`) || name.includes(toolName)).slice(0, 8);
69
+ // 流畅性(2026-11-04):只给「同族工具」在跨族猜错时是空手——例如
70
+ // `data.query`(平台缺席)或 `workflow.start`(其实是 data.submit)。
71
+ // closestMatches 按名字相似度补 3 个跨族候选,并把下一条命令直接写出来。
72
+ const nearest = closestMatches(toolName, names, 3).map((entry) => entry.name).filter((name) => !candidates.includes(name));
73
+ const didYouMean = [...candidates.slice(0, 3), ...nearest].slice(0, 5);
68
74
  // A requested operation the PLATFORM itself does not expose (e.g.
69
75
  // dictionary.delete) must not read as an OMC gap: name the real recovery
70
76
  // path and the documented absence instead of only the nearest tools.
@@ -73,8 +79,11 @@ export async function runToolCall(toolName, options = {}) {
73
79
  status: "unknown-tool",
74
80
  tool: toolName,
75
81
  ...(candidates.length > 0 ? { candidates } : {}),
82
+ ...(didYouMean.length > 0 ? { didYouMean, nextCommand: `omc describe ${didYouMean[0]}` } : {}),
76
83
  ...(absence ? { platformAbsence: absence.replace(/^;/, "") } : {}),
77
- hint: "工具名见 `omc commands --json --compact --lane <lane>`;用 next/recipe 定位",
84
+ // 跨族/表意不清时,「猜名字」本身就不该是路径——用目标路由一步到位。
85
+ goalCommand: `omc next "${toolName.replace(/[._]/g, " ")}"`,
86
+ hint: "工具名见 `omc commands --json --compact --lane <lane>`;不确定时 `omc next \"<目标>\"` 一步给路线(它会把\"流程提交\"这类表意映射到 data.submit)",
78
87
  };
79
88
  print(JSON.stringify(result, null, 2));
80
89
  return { exitCode: CALL_EXIT_CODES.unknownTool, result };
@@ -5,15 +5,28 @@ const ABSENCES = [
5
5
  evidence: "DataDictionaryController (cloudpivot-engine-8.6.26) 只有 saveDictionary / updateDictionaryStatus / deleteDictionaryClass(Batch);DictionaryFacade 的 deleteDictionaryRecord 无任何 controller 接线;apppackage/physically_delete 的 NodeType 分派只处理 Page/CustomPage/BizModel/Report,不含 DATA_DICTIONARY。管理端 UI 对字典本身只有「禁用」",
6
6
  message: "平台没有删除数据字典的操作(只有启用/停用)。字典的清理手段是 dictionary.status 停用字典与记录",
7
7
  },
8
+ {
9
+ // GitLab #18 P2-6(2026-11-04):调用方合理期待的「传一条只读 SQL」入口。
10
+ // 平台只暴露 bizservice method 执行面(service_test)与 DATA_TABLE 的
11
+ // get_table_column **SQL 模板**,没有自由 SQL 的 runtime HTTP 面。OMC 有意
12
+ // 不造一个:那会绕开方法注册/参数面/AES 配置/writable 语义与审计边界。
13
+ requested: "data.query",
14
+ aliases: ["sql.query", "data.sql", "data.querySql"],
15
+ recovery: "已注册的只读方法用 omc call method.test(DATA_SQL 适配器 + SELECT-only 可用 readOnlyOnReadOnlyEnv 在只读环境跑);聚合统计用 omc call data.list 加 filters 分片;表结构/SQL 模板用 service.table_sql(只回模板,不执行)",
16
+ evidence: "8.6.26 controller 面只有 BizServiceMethodController.service_test(:398-412,按已注册方法执行) 与 BizServiceFacadeImpl.getDataTableSql(:889-910,返回 DataTableSqlModel{sql,sqlType} 模板);不存在接收任意 SQL 文本的 runtime 查询端点。任何自由 SQL 入口都会绕开 bizservice 方法注册、参数面校验、configJson AES 配置与环境 writable 治理",
17
+ message: "平台没有「传一条 SQL 就执行」的只读查询通道(只有已注册方法的 service_test 与 SQL 模板发现)。为一次统计临时建/删方法属于生产配置写,正确路径是 method.test / data.list 分片 / service.table_sql",
18
+ },
8
19
  ];
9
- const BY_REQUESTED = new Map(ABSENCES.map((entry) => [entry.requested, entry]));
20
+ const BY_REQUESTED = new Map(ABSENCES.flatMap((entry) => [entry.requested, ...(entry.aliases ?? [])].map((name) => [name, entry])));
10
21
  /** The absence entry for a requested tool name, or undefined. Aliases resolve by exact name. */
11
22
  export function capabilityAbsence(requested) {
12
23
  const direct = BY_REQUESTED.get(requested);
13
24
  if (direct)
14
25
  return direct;
15
26
  // Tolerate a family wildcard the caller may write loosely (dictionary.deleteDictionary, delete.dictionary…).
16
- return ABSENCES.find((entry) => requested.includes(entry.requested) || (requested.startsWith("dictionary.") && requested.includes("delete")));
27
+ return ABSENCES.find((entry) => requested.includes(entry.requested)
28
+ || (entry.aliases ?? []).some((alias) => requested.includes(alias))
29
+ || (requested.startsWith("dictionary.") && requested.includes("delete")));
17
30
  }
18
31
  /** A `;…` suffix for an error message, or "" when the request names no known absence. */
19
32
  export function absencePointer(requested) {
@@ -30,8 +30,11 @@ export function envelope(status, result, requestId, error = null) {
30
30
  * default as Python's `json.dumps(ensure_ascii=True)`.
31
31
  *
32
32
  * POSIX always keeps raw UTF-8. `OMC_ASCII_STDOUT=1|0` forces either mode. `--out`
33
- * files, artifacts, stderr, and the CloudPivot wire stay raw UTF-8 — they are
34
- * not a code-page-bound console stream. See `CONTEXT.md` → Agent-facing CLI.
33
+ * files, artifacts, and the CloudPivot wire stay raw UTF-8 — they are not a
34
+ * code-page-bound console stream. `--utf8` speaks ONLY for human diagnostics
35
+ * (stderr/prose): it must never relax the envelope escape, or a cp936 pipe
36
+ * mangles the JSON structure itself (GitLab #17 T2). See `CONTEXT.md` →
37
+ * Agent-facing CLI.
35
38
  */
36
39
  export function asciiStdout() {
37
40
  const override = process.env.OMC_ASCII_STDOUT;
@@ -45,6 +48,17 @@ export function asciiStdout() {
45
48
  return false;
46
49
  return asciiStdoutFor(process.platform, process.stdout.isTTY === true, activeCodePage());
47
50
  }
51
+ /**
52
+ * T2(GitLab #17,2026-11-04):`--utf8` 是「人类可读文本按 UTF-8 原文输出」的
53
+ * 声明,**不再**等于「关掉 envelope 的 ASCII 转义」。旧实现让它改
54
+ * `OMC_ASCII_STDOUT=0`,于是在 GBK 管道下 JSON 信封也被写成原始 UTF-8,被
55
+ * PowerShell 按 936 解码后结构破坏(`ConvertFrom-Json` 在 result 中途失败)。
56
+ * stdout 的契约是「恰好一个 JSON envelope」,它必须永远可解析;代码页适配是
57
+ * 人类诊断(stderr/help 散文)的事。两者从此分开。
58
+ */
59
+ export function humanUtf8() {
60
+ return process.env.OMC_HUMAN_UTF8 === "1";
61
+ }
48
62
  /** Escape every non-ASCII code unit to `\uXXXX` (idempotent, JSON-safe). */
49
63
  export function escapeNonAscii(text) {
50
64
  return text.replace(/[\u007f-\uffff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
@@ -67,7 +81,9 @@ export function installCodePageSafeStderr() {
67
81
  if (stderrHardened)
68
82
  return;
69
83
  stderrHardened = true;
70
- if (!asciiStdout())
84
+ // `--utf8` 是「我的控制台/管道是 UTF-8」的声明:它只豁免人类诊断文本的转义,
85
+ // 不碰 stdout 的 JSON 信封(见 humanUtf8 的注释)。
86
+ if (!asciiStdout() || humanUtf8())
71
87
  return;
72
88
  const original = process.stderr.write.bind(process.stderr);
73
89
  process.stderr.write = ((chunk, encodingOrCb, maybeCb) => {
@@ -256,12 +272,31 @@ export function classifyResult(result) {
256
272
  const suffix = candidates.length > 0 ? `;did you mean: ${candidates.join("、")}` : ";用 `omc commands --json --compact --lane <lane>` 或 `omc next` 定位工具";
257
273
  return { status: "error", error: withNext({ code: "invalid-input", message: `unknown tool ${String(result.tool ?? "")}${suffix}`, retryable: false }), exitCode: CLI_EXIT_CODES.invalidInput };
258
274
  }
259
- if (result.blocker === "tool-input-invalid" || result.blocker === "tool-input-unknown-keys")
275
+ // r25 探针实证(2026-11-04):`blocker: "invalid-input"` 有 8 处产出点
276
+ // (workflow/runtime-data/compound-writes),但这里只认 `tool-input-*`——于是
277
+ // 它们全落到函数末尾的 internal 分支:`error.code=internal` + exit 15,被调用方
278
+ // 读成"CLI 内部故障",而实际是**自己传错了参数**(探针原话:
279
+ // 「rejected ✓, but error.code=internal (should be invalid-input), and no nextCommand」)。
280
+ // 入参形状错误一律 exit 10,并给出下一步。
281
+ const inputShapeBlocker = result.blocker === "tool-input-invalid"
282
+ || result.blocker === "tool-input-unknown-keys"
283
+ || result.blocker === "invalid-input";
284
+ if (inputShapeBlocker) {
285
+ // `invalid-input` 也可能是 CLI 层参数校验(compound-writes 的 --schema 必填),
286
+ // 那时没有 tool 字段——别硬拼一条指向不存在的工具的 describe。
287
+ const toolName = typeof result.tool === "string" && result.tool ? result.tool : undefined;
288
+ const nextCommand = typeof result.nextCommand === "string"
289
+ ? result.nextCommand
290
+ : toolName !== undefined ? `omc describe ${toolName}` : undefined;
260
291
  return {
261
292
  status: "error",
262
- error: { ...withNext({ code: "invalid-input", message: String(result.message ?? "tool input is invalid"), retryable: false }), nextCommand: `omc describe ${String(result.tool ?? "<tool>")}` },
293
+ error: {
294
+ ...withNext({ code: "invalid-input", message: String(result.message ?? "tool input is invalid"), retryable: false }),
295
+ ...(nextCommand !== undefined ? { nextCommand } : {}),
296
+ },
263
297
  exitCode: CLI_EXIT_CODES.invalidInput,
264
298
  };
299
+ }
265
300
  if (status === "uncertain")
266
301
  return { status, error: withNext({ code: "uncertain", message: String(result.message ?? result.reason ?? "outcome is uncertain"), retryable: true }), exitCode: CLI_EXIT_CODES.uncertain };
267
302
  if (status === "refused") {
@@ -44,10 +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).
47
+ // `--utf8` is consumed at module load (cli.ts maps it to OMC_HUMAN_UTF8=1
48
+ // BEFORE any output is produced — it relaxes stderr escaping only; stdout's
49
+ // JSON envelope stays ASCII-safe on purpose, GitLab #17 T2), so it must be
50
+ // stripped here too or commander rejects it as `unknown option` (r-finding
51
+ // 2026-10-30: the banner and README both taught `--utf8` while every
52
+ // invocation failed).
51
53
  if (token === "--utf8")
52
54
  continue;
53
55
  if (token === "--out") {
@@ -92,10 +94,10 @@ function emitAsciiBannerOnce() {
92
94
  // 只有「自动判定」成 ascii 时才提示:调用方显式设了 OMC_ASCII_STDOUT/--utf8
93
95
  // 说明已知情,再提示就是每次调用多两行噪音。
94
96
  // 注意:ascii 模式下 stderr 本身也会被转义,所以横幅必须是纯 ASCII。
95
- if (process.env.OMC_ASCII_STDOUT !== undefined)
97
+ if (process.env.OMC_ASCII_STDOUT !== undefined || process.env.OMC_HUMAN_UTF8 !== undefined)
96
98
  return;
97
- process.stderr.write("[omc] non-UTF-8 console code page: non-ASCII is \\u-escaped to survive the pipe. "
98
- + "Raw text: add --utf8, or `chcp 65001`, or set OMC_ASCII_STDOUT=0.\n");
99
+ process.stderr.write("[omc] non-UTF-8 console code page: the JSON envelope is \\u-escaped so it stays parseable. "
100
+ + "Human text: `chcp 65001`, or add --utf8 (stderr only; the envelope stays escaped by design).\n");
99
101
  }
100
102
  /**
101
103
  * Attach a repeat warning to a refusal that repeats the previous one. Refusal
@@ -103,6 +105,18 @@ function emitAsciiBannerOnce() {
103
105
  * caller is tuning arguments inside a dead end (r6-onboarding 2026-10-21: 16
104
106
  * repeat runs, 27 avoidable calls), so say so where it will be read.
105
107
  */
108
+ /**
109
+ * T3(GitLab #17,2026-11-04):「不要再微调参数重试」对**参数写错**是反向指导——
110
+ * `dir:"desc"` / `op:"ge"` 这类失败的正确下一步恰恰是照 acceptedValues /
111
+ * describe --param 改一个取值,而不是换工具。抽成纯函数以便钉死两种分支。
112
+ */
113
+ export function repeatFailureHint(status, blocker, shapeError, count) {
114
+ if (status === "uncertain")
115
+ return `同一调用已连续返回 uncertain ${count} 次。uncertain 表示结果未确定:先按 requestId 回读真实状态再决定,不要盲目重发写请求。`;
116
+ if (shapeError)
117
+ return `同一入参形状已连续失败 ${count} 次(blocker ${blocker ?? "invalid-input"})。这是可修的入参问题:按 acceptedValues 的合法取值、或 \`omc describe <tool> --param <name>\` 的参数表改**一处**再试;也可以 \`omc describe <tool> --example <id>\` 抄一份能跑的完整参数。改了两三次仍不过,再换工具或换路径。`;
118
+ return `同一调用已连续失败 ${count} 次(blocker ${blocker ?? "unknown"})。不要再微调参数重试:换形状或换工具——先 describe/配方确认契约,或按 blocker 指向的替代路径处理。`;
119
+ }
106
120
  function withRepeatWarning(value) {
107
121
  if (!value || typeof value !== "object")
108
122
  return value;
@@ -115,6 +129,7 @@ function withRepeatWarning(value) {
115
129
  const errorRecord = envelope.error && typeof envelope.error === "object" ? envelope.error : undefined;
116
130
  if (typeof errorRecord?.code === "string" && errorRecord.code === "auth-config")
117
131
  return value;
132
+ const shapeError = typeof errorRecord?.code === "string" && errorRecord.code === "invalid-input";
118
133
  const result = envelope.result && typeof envelope.result === "object" ? envelope.result : undefined;
119
134
  const blocker = typeof result?.blocker === "string" ? result.blocker : typeof envelope.blocker === "string" ? envelope.blocker : undefined;
120
135
  const prior = priorRepeatedFailures(currentUsageCommand(), blocker);
@@ -124,9 +139,7 @@ function withRepeatWarning(value) {
124
139
  sameFailureCount: prior.count + 1,
125
140
  blocker: blocker ?? null,
126
141
  ...(prior.requestIds.length > 0 ? { previousRequestIds: prior.requestIds } : {}),
127
- hint: status === "uncertain"
128
- ? `同一调用已连续返回 uncertain ${prior.count + 1} 次。uncertain 表示结果未确定:先按 requestId 回读真实状态再决定,不要盲目重发写请求。`
129
- : `同一调用已连续失败 ${prior.count + 1} 次(blocker ${blocker ?? "unknown"})。不要再微调参数重试:换形状或换工具——先 describe/配方确认契约,或按 blocker 指向的替代路径处理。`,
142
+ hint: repeatFailureHint(status, blocker, shapeError, prior.count + 1),
130
143
  };
131
144
  if (result)
132
145
  return { ...envelope, result: { ...result, repeatWarning } };
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ import { cmdFieldAdd } from "./field-add.js";
17
17
  import { cmdModelPurge } from "./model-purge.js";
18
18
  import { cmdProgrammingAdopt, cmdProgrammingApply, cmdProgrammingPull, cmdProgrammingStatus, cmdProgrammingTest } from "./programming-workflow.js";
19
19
  import { environmentWritable, requiresProductionConfirmation } from "./environment-policy.js";
20
- import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, installCodePageSafeStderr, refusalText, requestId } from "./cli-contract.js";
20
+ import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, humanUtf8, installCodePageSafeStderr, refusalText, requestId } from "./cli-contract.js";
21
21
  import { configureOutput, humanSummary, stripOutputFlags, writeEnvelope } from "./cli-output.js";
22
22
  import { beginUsage, completeUsage, currentUsageArgsDigest, ensureProjectIgnore, priorIdenticalSuccess, recordUsageOutput, usageReport } from "./usage-log.js";
23
23
  import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitThrown, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
@@ -31,9 +31,11 @@ import { cmdRuleWhen } from "./rule-when.js";
31
31
  import { cmdApprovalFlow } from "./approval-flow.js";
32
32
  import { runSubmitVerify, runRuleSaveEnable, runWorkflowUpdatePublish } from "./compound-writes.js";
33
33
  import { cmdFormDefault } from "./form-default.js";
34
- // --utf8:单次调用明文输出(等效 OMC_ASCII_STDOUT=0)。在 asciiStdout 缓存读取前生效。
34
+ // --utf8:人类可读文本(stderr 诊断/散文)按 UTF-8 原文输出。**不**关闭 stdout
35
+ // 的 ASCII 转义——JSON 信封必须在任何代码页下都能解析(GitLab #17 T2:旧实现让它
36
+ // 改 OMC_ASCII_STDOUT=0,GBK 管道下把信封写成原始 UTF-8,ConvertFrom-Json 中途失败)。
35
37
  if (process.argv.includes("--utf8"))
36
- process.env.OMC_ASCII_STDOUT = "0";
38
+ process.env.OMC_HUMAN_UTF8 = "1";
37
39
  // issue#9 C5:--utf8 是一等 flag 但帮助面零提及,用户退而设环境变量/chcp
38
40
  // (65/163 次 bash 白付前导)。默认 ASCII 转义时打一行自述提示。
39
41
  else if (process.env.OMC_ASCII_STDOUT !== "0" && process.argv.some((arg) => arg === "--help" || arg === "help")) {
@@ -95,6 +97,16 @@ async function cmdSetup(store, args) {
95
97
  console.error(`已删除环境 ${removeName}`);
96
98
  return 0;
97
99
  }
100
+ // GitLab #18 P2-7:交互式创建/编辑在非 TTY(管道、重定向、脚本)下走 readline,
101
+ // 读到 EOF 返回空串 → 每项都回落成 previous/空值 → upsert 仍执行 → 打印
102
+ // 「已保存」并 exit 0。调用方以为注册成功,实际什么都没改(实测 3 轮试管道/
103
+ // 变量/重定向才发现必须手工交互)。这里直接拒绝,给出真正可用的两条路。
104
+ if (!process.stdin.isTTY) {
105
+ console.error("setup 的交互式创建/编辑需要 TTY:当前 stdin 不是终端(管道/重定向),继续读会全部落空值并假装成功。\n"
106
+ + "改用:omc setup --list 查看已有环境;omc setup --remove <环境> 删除;或在真实终端里运行 omc setup --edit <环境>。\n"
107
+ + "脚本化注册请直接编辑配置(~/.config/omc/config.json 与 credentials,0600),不要 `echo ... | omc setup`。");
108
+ return 1;
109
+ }
98
110
  let name = editName ?? (await ask("环境名称(例如 test):")).trim();
99
111
  if (!name) {
100
112
  const fallback = store.listEnvironments().find((entry) => entry.profile.isDefault);
@@ -171,7 +183,14 @@ async function cmdDoctor(store, environment, serviceCode) {
171
183
  const chcp = spawnSync("chcp.com", [], { encoding: "utf8" });
172
184
  const active = /([0-9]{3,5})\s*$/.exec((chcp.stdout || "").trim().split("\n").pop() ?? "")?.[1];
173
185
  if (active && active !== "65001") {
174
- steps.push(`编码 ⚠ 活动代码页 ${active}(非 65001)——管道输出会把中文转义为 unicode 形式。要原样中文:\`chcp 65001\` + \`[Console]::OutputEncoding=[Text.Encoding]::UTF8\`,或设 \`OMC_ASCII_STDOUT=0\``);
186
+ // T2(GitLab #17):此处此前建议 `--utf8`/OMC_ASCII_STDOUT=0——在 936 管道
187
+ // 下那会把原始 UTF-8 直接塞进按 GBK 解码的管道,中文照样乱码(而且是
188
+ // **更糟**的乱码:JSON 结构会被破坏)。真实解法只有改代码页,或落文件。
189
+ steps.push(`编码 ⚠ 活动代码页 ${active}(非 65001):管道按该代码页解码 UTF-8,中文会乱码。可行解法只有两条——\`chcp 65001\` 后再跑;或把输出重定向到文件(UTF-8 写入)再用 UTF-8 读。--utf8 只影响人类诊断文本的转义策略,不改变管道解码方式,故此处不再推荐它`);
190
+ if (humanUtf8()) {
191
+ // 纯 ASCII:本行在 asciiStdout+humanUtf8 下不会被转义,混中文必乱码。
192
+ steps.push("encoding NOTE: --utf8 was given, but the pipe decodes as cp" + active + "; raw UTF-8 text will still look garbled. Use `chcp 65001` or write to a file and read it as UTF-8.");
193
+ }
175
194
  }
176
195
  else if (active === "65001") {
177
196
  steps.push("编码 ok 活动代码页 65001(UTF-8,中文输出无乱码风险)");
@@ -528,9 +547,17 @@ async function cmdCall(store, args) {
528
547
  // instructions contradicted each other and neither named the root. Offer a
529
548
  // single executable recipe: an absolute path inside the root.
530
549
  const outOfRoot = /must be inside the configuration root/.test(detail);
550
+ // 流畅性(GitLab #18 P3-8):PowerShell 把内联 JSON 拆坏时,报错形态是
551
+ // `Expected property name or '}' in JSON at position 1` / `Unexpected end of
552
+ // JSON input`——此时唯一有效的动作是改用文件或 stdin,而不是再调引号。
553
+ // 原来只在末尾附一句「若问题在引号转义…」,容易被当成补充说明;这里把它
554
+ // 提成主建议,并给出 PS 可粘贴的两条命令。
555
+ const looksLikeShellSplitting = /Unexpected (token|end of JSON)|Expected property name|position \d+/i.test(detail);
531
556
  const message = outOfRoot
532
557
  ? `--args-file 越界:${detail}。可执行做法:把参数文件放到 ${configRoot} 之下(如 ${join(configRoot, "args.json")}),再用 --args-file 引用;不要把参数文件放在系统临时目录(配置根之外一律拒绝)`
533
- : `--args 必须是合法 JSON:${detail}。若问题在引号转义(Windows PowerShell),改用 --args-file <配置根内的路径>`;
558
+ : looksLikeShellSplitting
559
+ ? `--args 不像合法 JSON(${detail})——这个形态通常是 shell 把内联 JSON 拆坏了(Windows PowerShell 的引号/转义规则),不是你写错了字段。改法二选一:① 把 JSON 写进 ${join(configRoot, "args.json")} 再用 --args-file;② \`omc call <tool> --args - \` 从 stdin 读(PS: \`'{"k":"v"}' | omc call <tool> --args -\`)。POSIX 文档里的单引号写法不能照抄到 PS`
560
+ : `--args 必须是合法 JSON:${detail}。若问题在引号转义(Windows PowerShell),改用 --args-file <配置根内的路径> 或 --args -(stdin)`;
534
561
  console.error(message);
535
562
  // The MACHINE envelope must carry the same actionable text: agents read
536
563
  // stdout, and the old envelope only repeated the English detail.
@@ -542,6 +569,21 @@ async function cmdCall(store, args) {
542
569
  }
543
570
  const registry = buildToolRegistry();
544
571
  const tool = registry.find((definition) => definition.name === toolName);
572
+ // 调用流畅性(2026-11-04,#18 的「查不到数据」同源):读面「宽松」不等于
573
+ // 「无声」——`{"limt":5}` / `{"filterr":{…}}` 被 zod 剥掉后返回全量,调用方
574
+ // 会读成「过滤没生效 / 数据就是这么多」。读面在**不阻断**的前提下把被忽略的
575
+ // 键回显出来(写面仍是 fail-closed,见 parseToolInput)。
576
+ const ignoredReadKeys = [];
577
+ if (tool && tool.kind !== "write") {
578
+ const schemaKeys = new Set(Object.keys(tool.inputSchema));
579
+ const recognized = new Set(["environment", "capability", "requestId", "dryRun"]);
580
+ for (const key of Object.keys(parsedArgs)) {
581
+ if (schemaKeys.has(key) || recognized.has(key))
582
+ continue;
583
+ const closest = closestMatches(key, [...schemaKeys], 1)[0]?.name;
584
+ ignoredReadKeys.push({ key, ...(closest !== undefined ? { closest } : {}) });
585
+ }
586
+ }
545
587
  if (tool) {
546
588
  try {
547
589
  parsedArgs = parseToolInput(tool, {
@@ -611,6 +653,17 @@ async function cmdCall(store, args) {
611
653
  if (projected !== undefined)
612
654
  result = projected;
613
655
  }
656
+ // 读面未知键的非阻断回显:放在投影**之后**,否则会被 --jq 的窄投影吞掉。
657
+ if (ignoredReadKeys.length > 0 && typeof result === "object" && result !== null) {
658
+ const pairs = ignoredReadKeys.map((entry) => (entry.closest ? `${entry.key}→${entry.closest}` : entry.key));
659
+ result = {
660
+ ...result,
661
+ ignoredArgs: {
662
+ keys: ignoredReadKeys,
663
+ hint: `这些参数本工具不认识,已被忽略(没有生效):${pairs.join("、")}。若是拼写问题按 → 改;要确认完整参数表用 \`omc describe ${toolName}\`。注意「过滤/排序没生效」最常见的原因就是这里拼错键。`,
664
+ },
665
+ };
666
+ }
614
667
  const classified = classifyResult(result);
615
668
  return emit(classified.status, result, classified.exitCode);
616
669
  }
@@ -664,7 +717,19 @@ function projectArtifactPath(value, path) {
664
717
  const fanout = projectJsonPathFanout(value, path);
665
718
  return fanout.missed
666
719
  ? { missed: true, ...(fanout.at !== undefined ? { at: fanout.at } : {}), ...(fanout.available !== undefined ? { available: fanout.available } : {}) }
667
- : { missed: false, content: fanout.values, mapped: fanout.values.length, ...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries } : {}) };
720
+ : { missed: false, content: fanout.values, mapped: fanout.values.length, ...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries } : {}), nullEntries: fanout.nullEntries };
721
+ }
722
+ /**
723
+ * T6(GitLab #17):`mapped`/`nullEntries` 的读法要跟数据一起走,否则 `mapped:5`
724
+ * 会被读成"取到 5 个值"。仅在确实出现 null 时加一行,正常读取零噪音。
725
+ */
726
+ function nullProjectionHint(content, nullEntries) {
727
+ if (nullEntries === undefined || nullEntries === 0)
728
+ return undefined;
729
+ const total = Array.isArray(content) ? content.length : 0;
730
+ if (total === 0 || nullEntries !== total)
731
+ return `其中 ${nullEntries} 个命中值为 null(不区分"字段为空"与"列表未投影")。`;
732
+ return `路径全部命中但值都是 null——mapped=${total} 只表示命中条目数。常见成因:列表视图未配置该列(data.list 只投影视图列)→ 用 data.load 读单条权威值,或先 listview.configure 配列。`;
668
733
  }
669
734
  function formatBytes(bytes) {
670
735
  if (bytes < 1024)
@@ -699,9 +764,24 @@ async function cmdArtifactGet(id, transport, options = {}) {
699
764
  // path (or pull the whole body); r5 hit that on 67 KB workflow templates.
700
765
  if (options.jq !== undefined) {
701
766
  const projected = projectArtifactPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
767
+ // T1(GitLab #17):形状 + 值 + 键表是**同一层**的三件事实,不再把值多套
768
+ // 一层 `projection:{path,content}`——`artifact get --jq` 与 `--summary --jq`
769
+ // 因此同形(顶层 path/content),换读面不用重新探根键。
702
770
  return emitTransportResult(projected.missed
703
- ? { 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) }
704
- : { 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);
771
+ ? { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, unanswered: true, hint: "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、数组映射 items[].fields.x、按键选择 rules[code=Create]。available 是该层真实存在的键。" }
772
+ : {
773
+ status: "ok",
774
+ artifactId: id,
775
+ sha256: artifact.sha256,
776
+ bytes: artifact.content.length,
777
+ keys,
778
+ path: options.jq,
779
+ content: projected.content ?? null,
780
+ ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}),
781
+ ...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}),
782
+ ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}),
783
+ ...(nullProjectionHint(projected.content, projected.nullEntries) !== undefined ? { hint: nullProjectionHint(projected.content, projected.nullEntries) } : {}),
784
+ }, transport);
705
785
  }
706
786
  return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, fetch: artifactFetchHint(id) }, transport);
707
787
  }
@@ -738,14 +818,14 @@ async function cmdArtifactGet(id, transport, options = {}) {
738
818
  const projected = projectArtifactPath(artifactContent, path);
739
819
  return projected.missed
740
820
  ? { path, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }
741
- : { path, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}) };
821
+ : { path, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}), ...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}), ...(nullProjectionHint(projected.content, projected.nullEntries) !== undefined ? { hint: nullProjectionHint(projected.content, projected.nullEntries) } : {}) };
742
822
  });
743
823
  return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, projections, ...(projections.some((entry) => entry.pathMiss) ? { unanswered: true, hint: "逗号多路径中部分路径未命中(见各 projection.pathMiss/missedAt/available);路径语法:点式 a.b、下标 a[0]、切片 a[0:6]、数组映射 items[].fields.x、按键选择 rules[code=Create]" } : {}) }, transport);
744
824
  }
745
825
  const projected = projectArtifactPath(artifactContent, options.jq);
746
826
  return emitTransportResult(projected.missed
747
827
  ? { 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];一次取多列用逗号分隔路径(--jq "a.b,c.d"),本参数是路径选择器、不是 jq 语言(不支持 | 与对象构造)` }
748
- : { 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);
828
+ : { 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 } : {}), ...(projected.nullEntries !== undefined && projected.nullEntries > 0 ? { nullEntries: projected.nullEntries } : {}), ...(nullProjectionHint(projected.content, projected.nullEntries) !== undefined ? { hint: nullProjectionHint(projected.content, projected.nullEntries) } : {}) }, transport);
749
829
  }
750
830
  // r6-onboarding 2026-10-21: 47 artifact fetches, 0 used a projection; the 10
751
831
  // large ones (up to 55 KB) pulled whole workflow templates just to read one
@@ -1516,7 +1596,7 @@ function cmdHelpSearch(topic, query, transport, searchMode, limit) {
1516
1596
  function onboardingHelp() {
1517
1597
  return [
1518
1598
  "首次使用、或不确定从哪开始:omc help omc-start-here(随包权威 topic 路线);被拒或状态异常:omc help omc-refusals。",
1519
- "中文可读输出:任何命令加 --utf8(如 omc --version --utf8)——默认终端输出会把非 ASCII 字符转义,中文场景先加它,不必设环境变量或 chcp。",
1599
+ "中文可读输出:任何命令加 --utf8——它让人类可读诊断(stderr/散文)按 UTF-8 原文输出。stdout 的 JSON 信封在非 UTF-8 代码页下仍保持 \\uXXXX 转义(保证 JSON.parse/ConvertFrom-Json 一定可解析,这是有意为之)。要连信封也是中文明文,必须先让管道按 UTF-8 解码(chcp 65001)——只设 OMC_ASCII_STDOUT=0 关写出端转义,在 cp936 管道下会把信封解码坏。",
1520
1600
  "方法论按需读:omc help <topic> 默认只回节索引(每节字节数);用 --section \"<标题>\" 取正文(可重复),确需整篇才 --all。找具体事实(错误码/字段名/命令名)不要整篇 dump:omc help --search \"<词>\"(跨 topic,命中节正文随结果返回)。",
1521
1601
  "目标→工具一步定位:omc next \"<你要做什么>\"(离线、只读,给能力/说明/首批命令;不确定会明说并回退到 omc-requirement-analysis)。",
1522
1602
  "大结果取值一步到位:读面回 detailArtifactId 时,用 omc call <tool> … --jq <path>(可重复;`[]` 映射数组,如 items[].fields.planNo)或 --extract/--extract-keys,省掉再跑一次 omc artifact get;artifact get 正文超 8KB 默认也只回形状(--jq/--keys 定向取,--full 才整包)。批量形状预检用 --args-list … --preflight(零 wire、零写入,一次报全)。",
@@ -82,7 +82,13 @@ export function artifactFetchHint(artifactId) {
82
82
  // raw. `--summary` shows the key/size map so they fetch only what they need.
83
83
  // Kept terse on purpose: this line rides EVERY write and every spilled read
84
84
  // (2026-09-25 cost pass — the parenthetical prose was 80 B of the 133 B).
85
- return `omc artifact get ${artifactId} --summary(再 --keys/--jq);或原调用加 --extract <path> / --extract-keys 一次拿到`;
85
+ //
86
+ // GitLab #18 P0-1: the old wording said `原调用加 --extract <path> /
87
+ // --extract-keys`, which reads as an `artifact get` option — `artifact get`
88
+ // has NO --extract (only --jq/--keys/--summary/--path-only/--full), so a
89
+ // caller following it always died on `unknown option '--extract'`. Name the
90
+ // owning face explicitly; the flags stay the same, just correctly routed.
91
+ return `omc artifact get ${artifactId} --summary(再 --keys/--jq);或回到产生它的那次 omc call 加 --extract <path> / --extract-keys(artifact get 没有 --extract,那里同义的是 --jq)`;
86
92
  }
87
93
  /** Replace local paths with a redaction marker (never leak the host layout). */
88
94
  function sanitizePaths(value) {
package/dist/discovery.js CHANGED
@@ -174,9 +174,13 @@ function summarizeDescription(description) {
174
174
  return sentence.length > 200 ? `${sentence.slice(0, 197)}...` : sentence;
175
175
  }
176
176
  /** A terse type label: `string`, `integer`, `array<string>`, `string: A|B`. */
177
- function terseType(node) {
177
+ function terseType(node, depth = 0) {
178
178
  if (!node)
179
179
  return "any";
180
+ // 审查 2026-11-04:展开嵌套 object 后递归可能遇到自引用 schema($refStrategy:none
181
+ // 通常已展开,但防御性设深度上限,超限退回 "object",绝不爆栈)。
182
+ if (depth > 3)
183
+ return "object";
180
184
  if (Array.isArray(node.enum) && node.enum.length > 0) {
181
185
  const values = node.enum.map((value) => String(value));
182
186
  const label = `${node.type ?? "string"}: ${values.join("|")}`;
@@ -185,12 +189,34 @@ function terseType(node) {
185
189
  // 券商 issue 1.2(2026-10-27):union 渲染成 "any" 曾让调用方以为 controls 收
186
190
  // 任意值、直觉传逗号串被 invalid_union 拒。anyOf 如实标注成员类型。
187
191
  if (Array.isArray(node.anyOf) && node.anyOf.length > 0) {
188
- const members = [...new Set(node.anyOf.map((member) => terseType(member)))];
192
+ const members = [...new Set(node.anyOf.map((member) => terseType(member, depth + 1)))];
189
193
  const label = members.join("|");
190
194
  return label.length > 60 ? `(${label.slice(0, 57)}...)` : label;
191
195
  }
192
196
  if (node.type === "array")
193
- return `array<${terseType(node.items)}>`;
197
+ return `array<${terseType(node.items, depth + 1)}>`;
198
+ // T3(GitLab #17,2026-11-04):枚举只写在 schema 的嵌套属性里(orders[].dir
199
+ // 的 ASC|DESC),而紧凑标签把整个 object 压成 "object"——调用方只能靠报错
200
+ // 试出合法值(`dir:"desc"` 被 zod 拒)。短枚举就内联,长了退回 "object"
201
+ // 以守住 describe 体积预算(默认面 4400B)。
202
+ if (node.type === "object" || node.properties !== undefined) {
203
+ const rendered = [];
204
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
205
+ if (!child)
206
+ continue;
207
+ if (Array.isArray(child.enum) && child.enum.length > 0)
208
+ rendered.push(`${key}:${child.enum.map((value) => String(value)).join("|")}`);
209
+ else {
210
+ const label = terseType(child, depth + 1);
211
+ if (label !== "any" && label !== "object")
212
+ rendered.push(`${key}:${label}`);
213
+ }
214
+ }
215
+ if (rendered.length === 0)
216
+ return "object";
217
+ const joined = `object{${rendered.join(",")}}`;
218
+ return joined.length > 80 ? "object" : joined;
219
+ }
194
220
  return node.type ?? "any";
195
221
  }
196
222
  /** Compact params: `required` list + `param -> type`, no per-param prose. */
@@ -428,6 +454,9 @@ export function describeTool(registry, name) {
428
454
  ...(documentedParamNames(tool).length > 0
429
455
  ? { paramDocs: { available: documentedParamNames(tool), read: `omc describe ${tool.name} --param <name>(单个参数注释,几百字节)` } }
430
456
  : {}),
457
+ // T3(GitLab #17):封闭取值(schema 里没有 enum 的那些)在默认面内联一次,
458
+ // 省掉「写错 → 报错 → 再写」的循环;报错里的 accepted* 仍是权威兜底。
459
+ ...(tool.paramEnums !== undefined ? { paramEnums: tool.paramEnums } : {}),
431
460
  // Keep the cost salient at the decision point: agents reached for --full even
432
461
  // when --example/--param existed (sandbox22: 8 --full calls, one 19 KB).
433
462
  full: `omc describe ${tool.name} --full(5–20KB;先试 --example/--param)`,
@@ -14,7 +14,11 @@ export async function admitGovernedWrite(ctx, request) {
14
14
  return settled(refusalFromCoverage(error));
15
15
  }
16
16
  const resolved = ctx.sessions.config().resolveEnvironment(request.environment);
17
- if (!environmentWritable(resolved.profile))
17
+ const writable = environmentWritable(resolved.profile);
18
+ // P2-5:只读环境默认拒绝;仅当调用方**显式**请求、且会话内证明这次用法只读,
19
+ // 才开一条窄缝。未请求/证明失败都回原来的 environment-write-disabled。
20
+ const escapeRequested = !writable && request.readOnlyEscape !== undefined && request.readOnlyEscape.requested(request.args ?? {});
21
+ if (!writable && !escapeRequested)
18
22
  return settled({
19
23
  status: "refused",
20
24
  blocker: "environment-write-disabled",
@@ -42,6 +46,26 @@ export async function admitGovernedWrite(ctx, request) {
42
46
  blocker: "version-family-evidence-missing",
43
47
  message: "writes fail closed when the CloudPivot version family cannot be established",
44
48
  });
49
+ // P2-5:只读放行的证明**放在版本门之后**——任何额外读回都要先过 fail-closed 的
50
+ // 版本证据门,保持「没有版本证据就不加新 wire 行为」的既有顺序。
51
+ let readOnlyEscape;
52
+ if (escapeRequested && request.readOnlyEscape !== undefined) {
53
+ let verdict;
54
+ try {
55
+ verdict = await request.readOnlyEscape.verify(session, request.args ?? {});
56
+ }
57
+ catch (error) {
58
+ return settled({
59
+ status: "refused",
60
+ blocker: "read-only-escape-unverified",
61
+ environment: resolved.name,
62
+ message: `只读放行的证明读回失败(${error instanceof Error ? error.message : String(error)});未执行任何试跑/查询。`,
63
+ });
64
+ }
65
+ if (!verdict.ok)
66
+ return settled({ ...verdict.refusal, environment: resolved.name });
67
+ readOnlyEscape = verdict.note;
68
+ }
45
69
  // Version family is advisory now: OMC is built on 8.6, but a deployment on a
46
70
  // different version (or product line, e.g. AI-Platform) is accepted in
47
71
  // compatibility mode. Missing evidence still fails closed above.
@@ -81,6 +105,7 @@ export async function admitGovernedWrite(ctx, request) {
81
105
  profile: resolved.profile,
82
106
  plan,
83
107
  ...(productionConfirmed ? { productionConfirmed: true } : {}),
108
+ ...(readOnlyEscape !== undefined ? { readOnlyEscape } : {}),
84
109
  },
85
110
  };
86
111
  }
@@ -181,7 +181,7 @@ export const B2_LANE_CONTRACT = Object.freeze({
181
181
  }),
182
182
  "service.table_sql": Object.freeze({
183
183
  scope: "readback",
184
- description: "表 SQL 模板发现:`/bizservice/method/get_table_column?serviceCode=&tableName=`。端点名虽为 column,实际返回**生成的 SQL 模板**(DataTableSqlModel{sql,sqlType ∈ SELECT/INSERT/UPDATE/DELETE/UPSERT},BizServiceFacadeImpl.getDataTableSql:889-910),不是列元数据;同样仅 DATA_TABLE 服务(501010)。纯只读。",
184
+ description: "表 SQL 模板发现:`/bizservice/method/get_table_column?serviceCode=&tableName=`。端点名虽为 column,实际返回**生成的 SQL 模板**(DataTableSqlModel{sql,sqlType ∈ SELECT/INSERT/UPDATE/DELETE/UPSERT},BizServiceFacadeImpl.getDataTableSql:889-910),不是列元数据;同样仅 DATA_TABLE 服务(501010)。纯只读。**只回模板、不执行**——平台没有自由 SQL 查询通道(要跑已注册的只读方法用 method.test,要查业务数据用 data.list 分片),别把本工具当查询入口(GitLab #18 P2-6)。",
185
185
  }),
186
186
  "service.delete": Object.freeze({
187
187
  scope: B2_SCOPE,