@stalfh233/omc-cli 0.0.0-stage → 0.4.0

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.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +304 -3
  3. package/coverage/m1-coverage-manifest-v1.json +1278 -0
  4. package/dist/approval-token.js +102 -0
  5. package/dist/args.js +25 -0
  6. package/dist/artifacts.js +119 -0
  7. package/dist/bench/call-face-eval.js +256 -0
  8. package/dist/bench/context-attribution.js +151 -0
  9. package/dist/bench/discovery-cost-eval.js +230 -0
  10. package/dist/bench/driver.js +81 -0
  11. package/dist/bench/evals.js +236 -0
  12. package/dist/bench/fake-http-server.js +65 -0
  13. package/dist/bench/instrument.js +87 -0
  14. package/dist/bench/intent-face-eval.js +343 -0
  15. package/dist/bench/run.js +166 -0
  16. package/dist/bench/scenario.js +343 -0
  17. package/dist/bench/types.js +76 -0
  18. package/dist/bi-wire.js +41 -0
  19. package/dist/bizservice-config.js +581 -0
  20. package/dist/call.js +153 -0
  21. package/dist/capability-absences.js +23 -0
  22. package/dist/capability-overview.js +492 -0
  23. package/dist/capability-shape.js +154 -0
  24. package/dist/cli-contract.js +70 -0
  25. package/dist/cli-output.js +73 -0
  26. package/dist/cli.js +1418 -0
  27. package/dist/code-rules.js +69 -0
  28. package/dist/command-transport.js +155 -0
  29. package/dist/config-store.js +195 -0
  30. package/dist/context.js +21 -0
  31. package/dist/contract-consistency.js +66 -0
  32. package/dist/contract-resources.js +62 -0
  33. package/dist/coverage-consistency.js +62 -0
  34. package/dist/coverage-registry.js +76 -0
  35. package/dist/coverage.js +130 -0
  36. package/dist/data-list-filter.js +114 -0
  37. package/dist/discovery.js +390 -0
  38. package/dist/endpoints.js +154 -0
  39. package/dist/environment-policy.js +26 -0
  40. package/dist/execution-metadata.js +1092 -0
  41. package/dist/fake/app.js +45 -0
  42. package/dist/fake/b2-registration.js +594 -0
  43. package/dist/fake/businessrule.js +242 -0
  44. package/dist/fake/datarule.js +72 -0
  45. package/dist/fake/dictionary.js +108 -0
  46. package/dist/fake/environment.js +30 -0
  47. package/dist/fake/field.js +167 -0
  48. package/dist/fake/form.js +101 -0
  49. package/dist/fake/index.js +121 -0
  50. package/dist/fake/list-view.js +289 -0
  51. package/dist/fake/model.js +136 -0
  52. package/dist/fake/online-js.js +18 -0
  53. package/dist/fake/report.js +253 -0
  54. package/dist/fake/routes.js +47 -0
  55. package/dist/fake/rule-lifecycle.js +37 -0
  56. package/dist/fake/runtime-data.js +367 -0
  57. package/dist/fake/state.js +67 -0
  58. package/dist/fake/workflow.js +364 -0
  59. package/dist/field-change.js +200 -0
  60. package/dist/field-families.js +896 -0
  61. package/dist/form-layout.js +111 -0
  62. package/dist/form-support.js +821 -0
  63. package/dist/goal-routes.js +468 -0
  64. package/dist/governed-execution.js +87 -0
  65. package/dist/human-summary.js +212 -0
  66. package/dist/identity.js +62 -0
  67. package/dist/intent/baseline.js +57 -0
  68. package/dist/intent/capabilities/bizservice.js +274 -0
  69. package/dist/intent/capabilities/businessrule.js +493 -0
  70. package/dist/intent/capabilities/datarule.js +187 -0
  71. package/dist/intent/capabilities/field.js +545 -0
  72. package/dist/intent/capabilities/form.js +136 -0
  73. package/dist/intent/capabilities/index.js +64 -0
  74. package/dist/intent/capabilities/listview.js +157 -0
  75. package/dist/intent/capabilities/model.js +100 -0
  76. package/dist/intent/capabilities/onlinejs.js +108 -0
  77. package/dist/intent/capabilities/report.js +355 -0
  78. package/dist/intent/capabilities/workflow.js +458 -0
  79. package/dist/intent/capability.js +6 -0
  80. package/dist/intent/cli.js +91 -0
  81. package/dist/intent/compare.js +56 -0
  82. package/dist/intent/compiler.js +79 -0
  83. package/dist/intent/dsl.js +129 -0
  84. package/dist/intent/plan-file.js +63 -0
  85. package/dist/intent/readback.js +65 -0
  86. package/dist/intent/schema.js +158 -0
  87. package/dist/intent/validation.js +30 -0
  88. package/dist/intent/yaml.js +315 -0
  89. package/dist/json-column.js +68 -0
  90. package/dist/lanes/app-contract.js +95 -0
  91. package/dist/lanes/app-coverage.js +16 -0
  92. package/dist/lanes/app.js +174 -0
  93. package/dist/lanes/apply-changes.js +231 -0
  94. package/dist/lanes/b2-registration-contract.js +292 -0
  95. package/dist/lanes/b2-registration-coverage.js +48 -0
  96. package/dist/lanes/b2-registration.js +1187 -0
  97. package/dist/lanes/businessrule-contract.js +232 -0
  98. package/dist/lanes/businessrule-coverage.js +16 -0
  99. package/dist/lanes/businessrule.js +221 -0
  100. package/dist/lanes/contract-support.js +65 -0
  101. package/dist/lanes/coverage-declaration.js +9 -0
  102. package/dist/lanes/datarule-contract.js +155 -0
  103. package/dist/lanes/datarule-coverage.js +19 -0
  104. package/dist/lanes/datarule-protocol.js +308 -0
  105. package/dist/lanes/datarule.js +818 -0
  106. package/dist/lanes/dictionary-contract.js +78 -0
  107. package/dist/lanes/dictionary-coverage.js +24 -0
  108. package/dist/lanes/dictionary.js +235 -0
  109. package/dist/lanes/environment-contract.js +70 -0
  110. package/dist/lanes/environment-coverage.js +14 -0
  111. package/dist/lanes/environment.js +163 -0
  112. package/dist/lanes/field-contract.js +223 -0
  113. package/dist/lanes/field-coverage.js +27 -0
  114. package/dist/lanes/field.js +374 -0
  115. package/dist/lanes/form-contract.js +97 -0
  116. package/dist/lanes/form-coverage.js +16 -0
  117. package/dist/lanes/form.js +185 -0
  118. package/dist/lanes/lane-ids.js +34 -0
  119. package/dist/lanes/list-view-contract.js +175 -0
  120. package/dist/lanes/list-view-coverage.js +20 -0
  121. package/dist/lanes/list-view-shapes.js +1207 -0
  122. package/dist/lanes/list-view.js +578 -0
  123. package/dist/lanes/meta-contract.js +85 -0
  124. package/dist/lanes/meta.js +255 -0
  125. package/dist/lanes/model-contract.js +168 -0
  126. package/dist/lanes/model-coverage.js +20 -0
  127. package/dist/lanes/model.js +986 -0
  128. package/dist/lanes/online-js-contract.js +99 -0
  129. package/dist/lanes/online-js-coverage.js +28 -0
  130. package/dist/lanes/online-js.js +127 -0
  131. package/dist/lanes/report-contract.js +144 -0
  132. package/dist/lanes/report-coverage.js +21 -0
  133. package/dist/lanes/report.js +476 -0
  134. package/dist/lanes/rule-graph.js +1846 -0
  135. package/dist/lanes/rule-lifecycle-contract.js +92 -0
  136. package/dist/lanes/rule-lifecycle-coverage.js +20 -0
  137. package/dist/lanes/rule-lifecycle.js +176 -0
  138. package/dist/lanes/runtime-data-contract.js +264 -0
  139. package/dist/lanes/runtime-data-coverage.js +25 -0
  140. package/dist/lanes/runtime-data.js +1054 -0
  141. package/dist/lanes/workflow-contract.js +223 -0
  142. package/dist/lanes/workflow-coverage.js +40 -0
  143. package/dist/lanes/workflow.js +1813 -0
  144. package/dist/online-js-layout.js +58 -0
  145. package/dist/online-js-source.js +276 -0
  146. package/dist/package-tool.js +51 -0
  147. package/dist/package.js +73 -0
  148. package/dist/plan.js +73 -0
  149. package/dist/read.js +144 -0
  150. package/dist/redact.js +28 -0
  151. package/dist/rule-support.js +587 -0
  152. package/dist/runtime-support.js +134 -0
  153. package/dist/server.js +92 -0
  154. package/dist/session-manager.js +30 -0
  155. package/dist/session.js +149 -0
  156. package/dist/skills.js +112 -0
  157. package/dist/support.js +98 -0
  158. package/dist/tool-types.js +127 -0
  159. package/dist/tools.js +59 -0
  160. package/dist/usage-log.js +197 -0
  161. package/dist/wire.js +287 -0
  162. package/dist/workflow-support.js +99 -0
  163. package/dist/write-lease.js +26 -0
  164. package/dist/write-lock.js +109 -0
  165. package/dist/write.js +176 -0
  166. package/dist/zip.js +156 -0
  167. package/docs/tool-surface-map.md +42 -0
  168. package/package.json +71 -6
  169. package/skills/omc-acceptance-criteria.md +47 -0
  170. package/skills/omc-business-configuration.md +257 -0
  171. package/skills/omc-capabilities.md +197 -0
  172. package/skills/omc-capability-scouting.md +55 -0
  173. package/skills/omc-config-draft-review.md +171 -0
  174. package/skills/omc-five-piece-flow.md +35 -0
  175. package/skills/omc-glossary.md +86 -0
  176. package/skills/omc-refusals.md +110 -0
  177. package/skills/omc-requirement-analysis.md +161 -0
  178. package/skills/omc-requirement-vocabulary.md +51 -0
  179. package/skills/omc-start-here.md +82 -0
  180. package/skills/omc-tool-selection.md +119 -0
  181. package/skills/omc-write-hazards.md +87 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1418 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin, argv, exit } from "node:process";
4
+ import { Command, CommanderError } from "commander";
5
+ import { ConfigStore, defaultPaths, removedProfileKeys } from "./config-store.js";
6
+ import { loadCoverageManifest, versionFamilyNotice } from "./coverage.js";
7
+ import { redactText } from "./redact.js";
8
+ import { readArgsFile, readStdinArgs, runToolCall, validateAgentPaths } from "./call.js";
9
+ import { environmentWritable, requiresProductionConfirmation } from "./environment-policy.js";
10
+ import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, requestId } from "./cli-contract.js";
11
+ import { configureOutput, humanSummary, stripOutputFlags, writeEnvelope } from "./cli-output.js";
12
+ import { beginUsage, completeUsage, usageReport } from "./usage-log.js";
13
+ import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
14
+ import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath, createArtifact } from "./artifacts.js";
15
+ import { jsonBytes, sha256, writeArtifact } from "./support.js";
16
+ import { join, resolve } from "node:path";
17
+ import { writeFileSync } from "node:fs";
18
+ import { buildToolRegistry } from "./tools.js";
19
+ import { acquireWriteLock } from "./write-lock.js";
20
+ import { commandIndex, commandInventory, describeContract, describeContractFull, describeContractTool, describeExample, describeParam, describeSchemaOnly, describeTool, describeToolFull } from "./discovery.js";
21
+ import { buildCapabilityOverview, capabilityDetail, compactCapabilities, executableCapabilityBundle, resolveCapabilityId, DISCOVERY_AUTHORITY } from "./capability-overview.js";
22
+ import { goalRoute } from "./goal-routes.js";
23
+ import { absencePointer } from "./capability-absences.js";
24
+ import { intentConfigRoot, readIntentFile, validateIntentText, INTENT_COMPILER_VERSION } from "./intent/cli.js";
25
+ import { intentSchemaBlock, intentExample, intentExampleIds } from "./intent/schema.js";
26
+ import { baselineFingerprintOf, readBaseline } from "./intent/baseline.js";
27
+ import { compileIntent } from "./intent/compiler.js";
28
+ import { expectedRuleGraph } from "./intent/capabilities/businessrule.js";
29
+ import { validateRuleNodeGraph, PLATFORM_TRIGGER_RULE_CODES, walkConditionLeaves } from "./lanes/rule-graph.js";
30
+ import { buildPlan, verifyPlan } from "./intent/plan-file.js";
31
+ import { compareWithLive, decodeBaseline } from "./intent/readback.js";
32
+ import { listSkills, readSkill, readSkillSection } from "./skills.js";
33
+ import { parseToolInput, ToolInputError, ToolRefusalError } from "./tool-types.js";
34
+ import { WireError, wireCall } from "./wire.js";
35
+ import { ENDPOINTS } from "./endpoints.js";
36
+ import { makeToolContext } from "./context.js";
37
+ import { SessionManager } from "./session-manager.js";
38
+ async function ask(question) {
39
+ const rl = createInterface({ input: stdin, output: process.stderr });
40
+ try {
41
+ return await rl.question(question);
42
+ }
43
+ finally {
44
+ rl.close();
45
+ }
46
+ }
47
+ /** Hidden password prompt (no echo). */
48
+ async function askSecret(question) {
49
+ const { createInterface } = await import("node:readline");
50
+ const rl = createInterface({ input: stdin, output: process.stderr, terminal: true });
51
+ const writeOutput = rl._writeToOutput.bind(rl);
52
+ rl._writeToOutput = (text) => {
53
+ if (text.includes(question))
54
+ writeOutput(text);
55
+ };
56
+ return new Promise((resolve) => {
57
+ rl.question(question, (answer) => {
58
+ rl.close();
59
+ resolve(answer);
60
+ });
61
+ });
62
+ }
63
+ async function cmdSetup(store, args) {
64
+ const flag = (name) => {
65
+ const index = args.indexOf(name);
66
+ return index >= 0 ? args[index + 1] : undefined;
67
+ };
68
+ if (args.includes("--list")) {
69
+ for (const { name, profile } of store.listEnvironments())
70
+ console.error(`${name}${profile.isDefault ? "(默认)" : ""} [${profile.production ? "生产环境" : "测试环境"},${environmentWritable(profile) ? "允许写入" : "只读"}${profile.production ? "(生产写需逐笔 confirmProduction)" : ""}${removedProfileKeys(profile).length > 0 ? `,⚠ 含已移除键:${removedProfileKeys(profile).join("、")}` : ""}] -> ${profile.baseUrl} 应用=${profile.applicationCode}${profile.engineCode ? ` 租户=${profile.engineCode}` : ""}`);
71
+ return 0;
72
+ }
73
+ const editName = flag("--edit");
74
+ const removeName = flag("--remove");
75
+ if (removeName) {
76
+ store.removeEnvironment(removeName);
77
+ console.error(`已删除环境 ${removeName}`);
78
+ return 0;
79
+ }
80
+ let name = editName ?? (await ask("环境名称(例如 test):")).trim();
81
+ if (!name) {
82
+ const fallback = store.listEnvironments().find((entry) => entry.profile.isDefault);
83
+ if (!fallback) {
84
+ console.error("必须填写环境名称(当前没有默认环境可回退)");
85
+ return 1;
86
+ }
87
+ name = fallback.name;
88
+ console.error(`未输入名称,改为编辑默认环境 ${name}(用 --list 查看全部,用 --edit <环境> 选择其它)`);
89
+ }
90
+ const previous = store.readConfig().environments[name];
91
+ const baseUrl = (await ask(`CloudPivot 服务地址${previous ? ` [${previous.baseUrl}]` : ""}:`)).trim() || previous?.baseUrl;
92
+ const applicationCode = (await ask(`默认应用 code(可选,留空则由 Agent 运行时发现应用)${previous?.applicationCode ? ` [${previous.applicationCode}]` : ""}:`)).trim() || previous?.applicationCode || "";
93
+ const engineCode = (await ask(`多租户 engineCode(可选;仅当网关按 x-lowcode-enginecode 路由、不带则 404 时填写,留空为单租户)${previous?.engineCode ? ` [${previous.engineCode}]` : ""}:`)).trim() || previous?.engineCode || "";
94
+ const username = (await ask(`登录账号${previous ? " [保留已存账号]" : ""}:`)).trim();
95
+ const password = username ? await askSecret("登录密码(输入不回显,以 0600 存储,绝不外泄):") : "";
96
+ const defaultAnswer = (await ask(`设为默认环境?[y/N]${previous?.isDefault ? "(当前已是默认)" : ""}:`)).trim().toLowerCase();
97
+ const isDefault = defaultAnswer ? defaultAnswer === "y" : previous?.isDefault === true;
98
+ const productionAnswer = (await ask(`这是生产环境吗?(风险标签:生产写入每次都要显式高危确认,但仍由 writable 决定能否写)[y/N]${previous?.production ? "(当前是生产)" : ""}:`)).trim().toLowerCase();
99
+ const production = productionAnswer ? productionAnswer === "y" : previous?.production === true;
100
+ const writableAnswer = (await ask(`允许在这个环境执行新增、修改、删除和发布吗?(唯一的写入开关)[y/N]${environmentWritable(previous ?? { baseUrl: "", applicationCode: "", production }) ? "(当前允许写入)" : "(当前只读)"}:`)).trim().toLowerCase();
101
+ const writable = writableAnswer ? writableAnswer === "y" : previous?.writable ?? false;
102
+ store.upsertEnvironment(name, {
103
+ baseUrl,
104
+ applicationCode,
105
+ ...(engineCode ? { engineCode } : {}),
106
+ ...(isDefault ? { isDefault: true } : {}),
107
+ production,
108
+ writable,
109
+ });
110
+ if (username && password)
111
+ store.writeCredential(name, { username, password });
112
+ const bind = (await ask("将本项目(.omc-env)绑定到该环境?[y/N]:")).toLowerCase() === "y";
113
+ if (bind)
114
+ store.bindProjectEnvironment(name);
115
+ console.error(`环境 ${name} 已保存(幂等,可随时重跑 omc setup)。凭据存储模式:${store.credentialsMode()}`);
116
+ return 0;
117
+ }
118
+ async function cmdDoctor(store, environment) {
119
+ const manifest = loadCoverageManifest();
120
+ const steps = [];
121
+ let ok = true;
122
+ try {
123
+ const resolved = store.resolveEnvironment(environment);
124
+ steps.push(`配置 ok 环境=${resolved.name}(来源 ${resolved.source})`);
125
+ steps.push(`写入 ok 写入=${environmentWritable(resolved.profile) ? "允许" : "只读"}${requiresProductionConfirmation(resolved.profile) ? ";生产环境:每笔写入需 confirmProduction" : ""}`);
126
+ const removed = removedProfileKeys(resolved.profile);
127
+ if (removed.length > 0)
128
+ steps.push(`配置 ⚠ 含已移除键(请删除):${removed.join("、")}`);
129
+ if (resolved.profile.engineCode)
130
+ steps.push(`租户 ok 多租户 engineCode=${resolved.profile.engineCode}(随每笔请求发 x-lowcode-enginecode)`);
131
+ const credential = store.readCredential(resolved.name);
132
+ steps.push(`凭据 ok 模式=${store.credentialsMode()} 账号=${credential.username}`);
133
+ const { CloudPivotSession } = await import("./session.js");
134
+ const session = new CloudPivotSession(resolved.name, resolved.profile, credential);
135
+ await session.login();
136
+ steps.push(`登录 ok 企业=${session.enterpriseId()} 管理员=${session.administrator()}`);
137
+ const version = session.detectedVersion();
138
+ if (!version) {
139
+ steps.push(`版本 WARN 无版本证据;写入将保守拒绝(fail closed)`);
140
+ }
141
+ else {
142
+ const notice = versionFamilyNotice(manifest, version);
143
+ if (notice)
144
+ steps.push(`版本 WARN ${notice}`);
145
+ else
146
+ steps.push(`版本 ok ${version}(族 ${manifest.version_family},锚点 ${manifest.anchor_version})`);
147
+ }
148
+ steps.push(`环境指纹 ok ${session.environmentFingerprint()}`);
149
+ // A CloudPivot license expiry answers 109001 on apppackage/list and turns
150
+ // the whole surface (most reads and every write) into refusals. Config,
151
+ // credentials, login, version and fingerprint all still pass, so without
152
+ // this probe doctor reports "通过" on an environment nothing can operate.
153
+ try {
154
+ await wireCall(session, { path: ENDPOINTS.applicationList, query: {} });
155
+ steps.push(`许可证 ok 平台 apppackage/list 可访问`);
156
+ }
157
+ catch (error) {
158
+ const errcode = error instanceof WireError ? error.errcode : undefined;
159
+ if (errcode === 109001 || /许可证|license/i.test(error.message)) {
160
+ ok = false;
161
+ steps.push(`许可证 FAIL 已过期(平台拒绝 apppackage/list:${String(errcode ?? "")})——续期或更换环境;否则几乎全部读面与所有写面都会被 109001 拒绝`);
162
+ }
163
+ else {
164
+ ok = false;
165
+ steps.push(`许可证 FAIL 许可证面探测失败:${redactText(error.message, [])}`);
166
+ }
167
+ }
168
+ }
169
+ catch (error) {
170
+ ok = false;
171
+ steps.push(`自检 FAIL ${redactText(error.message, [])}`);
172
+ }
173
+ for (const line of steps)
174
+ console.error(line);
175
+ // Cold-start anchor (round 2, module D): `doctor` runs every session, so it
176
+ // carries the minimal-read path, the artifact convention, and the readiness
177
+ // command — the facts a first-time agent otherwise assembles from ~52 KB of
178
+ // prose. Kept terse so the anchor stays ~1 KB.
179
+ console.error(doctorAnchor());
180
+ console.error(ok ? "doctor:通过" : "doctor:失败");
181
+ return ok ? 0 : 1;
182
+ }
183
+ /** The cold-start anchor `doctor` appends: minimal path, artifact convention, readiness. */
184
+ function doctorAnchor() {
185
+ const topics = listSkills().map((entry) => entry.name).join("、");
186
+ return [
187
+ "最小路径 omc next \"<目标>\"(目标→工具一步定位,不确定会明说)→ omc help omc-start-here → omc call discover_environment --env <env> --args '{}'(<env> 名以 doctor 的「环境=」或项目 .omc-env 为准,勿猜)→ omc capabilities / omc describe <tool>",
188
+ `方法论 omc help <topic> --sections / --section "<标题>"(按节读,随包 skills/);topic:${topics}`,
189
+ "大输出 超预算结果只回 artifactId/sha256/fetch;用 fetch 里的 omc artifact get <id>(可加 --jq <path>)取回,勿全量读入",
190
+ ].join("\n");
191
+ }
192
+ function flag0(args, name) {
193
+ const index = args.indexOf(name);
194
+ return index >= 0 ? args[index + 1] : undefined;
195
+ }
196
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
197
+ async function cmdCall(store, args) {
198
+ const request = flag0(args, "--request-id") ?? requestId();
199
+ let maxOutputBytes;
200
+ try {
201
+ maxOutputBytes = outputBudget(args);
202
+ }
203
+ catch (error) {
204
+ const message = error instanceof Error ? error.message : String(error);
205
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message, retryable: false }));
206
+ return CLI_EXIT_CODES.invalidInput;
207
+ }
208
+ const emit = (status, result, exitCode) => {
209
+ if (status === "uncertain" && result.nextAction === undefined)
210
+ result = { ...result, nextAction: "read back the requestId before retrying; retry only after the outcome is established" };
211
+ const classified = classifyResult(result);
212
+ writeEnvelope(envelope(status, boundResult(result, maxOutputBytes), request, classified.error));
213
+ return exitCode;
214
+ };
215
+ const flag = (name) => {
216
+ const index = args.indexOf(name);
217
+ return index >= 0 ? args[index + 1] : undefined;
218
+ };
219
+ const positional = args.filter((token, index) => !token.startsWith("--") && (index === 0 || !args[index - 1]?.startsWith("--env") && !args[index - 1]?.startsWith("--environment") && !args[index - 1]?.startsWith("--args")));
220
+ const toolName = positional[0];
221
+ if (!toolName || toolName === "help" || args.includes("--help")) {
222
+ console.error("用法:omc call <tool> [--env <环境>] [--args '<json>' | --args - (stdin) | --args-file <path>]");
223
+ console.error("写入:由环境 writable 决定(只读环境拒写);生产环境的写必须在 args 里带 confirmProduction:true。");
224
+ return emit(toolName && toolName !== "help" ? "error" : "ok", {}, toolName && toolName !== "help" ? CLI_EXIT_CODES.invalidInput : 0);
225
+ }
226
+ const environment = flag("--env") ?? flag("--environment");
227
+ const requestValue = flag("--request-id");
228
+ const dryRun = args.includes("--dry-run");
229
+ const waitValue = flag("--wait");
230
+ let waitMs = 0;
231
+ if (waitValue !== undefined) {
232
+ waitMs = Number(waitValue);
233
+ if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 600_000) {
234
+ const message = "--wait must be an integer number of milliseconds from 0 to 600000";
235
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message, retryable: false }));
236
+ return CLI_EXIT_CODES.invalidInput;
237
+ }
238
+ }
239
+ let timeoutMs;
240
+ try {
241
+ if (requestValue !== undefined && requestValue.length === 0)
242
+ throw new Error("--request-id must not be empty");
243
+ const value = flag("--timeout-ms");
244
+ if (value !== undefined) {
245
+ timeoutMs = Number(value);
246
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 600_000)
247
+ throw new Error("--timeout-ms must be an integer from 1 to 600000");
248
+ }
249
+ }
250
+ catch (error) {
251
+ const message = error instanceof Error ? error.message : String(error);
252
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message, retryable: false }));
253
+ return CLI_EXIT_CODES.invalidInput;
254
+ }
255
+ if (timeoutMs !== undefined)
256
+ process.env.OMC_HTTP_TIMEOUT_MS = String(timeoutMs);
257
+ const rawArgs = flag("--args");
258
+ const argsFile = flag("--args-file");
259
+ let parsedArgs = {};
260
+ try {
261
+ if (rawArgs !== undefined && argsFile !== undefined)
262
+ throw new Error("--args and --args-file are mutually exclusive");
263
+ if (rawArgs === "-")
264
+ parsedArgs = readStdinArgs();
265
+ else if (argsFile)
266
+ parsedArgs = readArgsFile(argsFile, resolve(process.env.OMC_CONFIG_HOME ?? process.cwd()));
267
+ else if (rawArgs) {
268
+ parsedArgs = JSON.parse(rawArgs);
269
+ if (parsedArgs === null || typeof parsedArgs !== "object" || Array.isArray(parsedArgs))
270
+ throw new Error("--args must be a JSON object");
271
+ }
272
+ validateAgentPaths(parsedArgs);
273
+ }
274
+ catch (error) {
275
+ console.error(`--args 必须是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
276
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: error instanceof Error ? error.message : String(error), retryable: false }));
277
+ return CLI_EXIT_CODES.invalidInput;
278
+ }
279
+ const registry = buildToolRegistry();
280
+ const tool = registry.find((definition) => definition.name === toolName);
281
+ if (tool) {
282
+ try {
283
+ parsedArgs = parseToolInput(tool, {
284
+ ...parsedArgs,
285
+ ...(environment && parsedArgs.environment === undefined ? { environment } : {}),
286
+ ...(requestValue !== undefined ? { requestId: requestValue } : {}),
287
+ ...(dryRun ? { dryRun: true } : {}),
288
+ });
289
+ }
290
+ catch (error) {
291
+ const result = error instanceof ToolInputError ? error.payload : { status: "error", message: error instanceof Error ? error.message : String(error) };
292
+ return emit("error", result, CLI_EXIT_CODES.invalidInput);
293
+ }
294
+ }
295
+ const toolKind = tool?.kind;
296
+ let lock;
297
+ // A `--dry-run` never mutates the environment: the governed pipeline settles it
298
+ // before write admission, so it must not contend for the cross-process lock.
299
+ if (toolKind === "write" && !dryRun) {
300
+ const lockTarget = environment ?? resolvedDefaultEnvironment(store);
301
+ const deadline = Date.now() + waitMs;
302
+ for (;;) {
303
+ lock = await acquireWriteLock(lockTarget);
304
+ if (lock.held)
305
+ break;
306
+ if (Date.now() >= deadline) {
307
+ const conflict = {
308
+ status: "refused",
309
+ blocker: "write-lock-held",
310
+ environment: lockTarget,
311
+ owner: lock.owner,
312
+ recovery: lock.recovery,
313
+ message: lock.recovery[0],
314
+ };
315
+ console.error(`write lock conflict: ${JSON.stringify(lock.owner)}`);
316
+ writeEnvelope(envelope("refused", conflict, request, classifyResult(conflict).error));
317
+ return CLI_EXIT_CODES.policyRefused;
318
+ }
319
+ await sleep(Math.min(200, Math.max(0, deadline - Date.now())));
320
+ }
321
+ }
322
+ let timedOut = false;
323
+ let result;
324
+ try {
325
+ ({ result } = await runToolCall(toolName, {
326
+ args: parsedArgs,
327
+ ...(environment ? { environment } : {}),
328
+ ...(requestValue !== undefined ? { requestId: requestValue } : {}),
329
+ dryRun,
330
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
331
+ print: (line) => humanSummary(line),
332
+ }));
333
+ if (result?.status === "uncertain" && result?.blocker === "cli-timeout")
334
+ timedOut = true;
335
+ }
336
+ finally {
337
+ if (lock?.held) {
338
+ if (timedOut) {
339
+ console.error(`cli-timeout: the write may still be in flight; the environment lock is deliberately NOT released — it auto-recovers once pid ${process.pid} exits (stale recovery)`);
340
+ }
341
+ else
342
+ await lock.release();
343
+ }
344
+ }
345
+ const classified = classifyResult(result);
346
+ return emit(classified.status, result, classified.exitCode);
347
+ }
348
+ function resolvedDefaultEnvironment(store) {
349
+ try {
350
+ return store.resolveEnvironment(undefined).name;
351
+ }
352
+ catch {
353
+ return "default";
354
+ }
355
+ }
356
+ /**
357
+ * Bounded JSON-path projection for `artifact get --jq`. Supports dotted
358
+ * (`content.activities.0.name`) and slashed (`content/activities/0/name`)
359
+ * segments. Not a general jq: a path walk only, so it can never evaluate code.
360
+ */
361
+ function projectJsonPath(value, path) {
362
+ const segments = path.replace(/^\//, "").split(/[./]/).filter((segment) => segment.length > 0);
363
+ let current = value;
364
+ for (const segment of segments) {
365
+ if (Array.isArray(current)) {
366
+ const index = Number(segment);
367
+ if (!Number.isInteger(index) || index < 0 || index >= current.length)
368
+ return undefined;
369
+ current = current[index];
370
+ }
371
+ else if (current && typeof current === "object") {
372
+ const record = current;
373
+ if (!Object.prototype.hasOwnProperty.call(record, segment))
374
+ return undefined;
375
+ current = record[segment];
376
+ }
377
+ else {
378
+ return undefined;
379
+ }
380
+ }
381
+ return current;
382
+ }
383
+ async function cmdArtifactGet(id, transport, options = {}) {
384
+ try {
385
+ const artifact = readArtifact(artifactRoot(), id);
386
+ let content;
387
+ try {
388
+ content = JSON.parse(artifact.content.toString("utf8"));
389
+ }
390
+ catch {
391
+ content = artifact.content.toString("utf8");
392
+ }
393
+ // `--path-only` deliberately does NOT return the local path: the Agent-facing
394
+ // CLI contract forbids exposing local filesystem paths. It returns the fetch
395
+ // command instead, which is the portable way to reach the body.
396
+ if (options.pathOnly)
397
+ return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, fetch: artifactFetchHint(id) }, transport);
398
+ // `--summary` / `--keys` (round 4): inspect a large artifact without pulling
399
+ // its whole body. Reproduced benchmark: agents fetched 30–75 KB workflow
400
+ // bodies just to look at the node list.
401
+ if (options.summary) {
402
+ const record = content && typeof content === "object" && !Array.isArray(content) ? content : undefined;
403
+ const keys = record ? Object.entries(record).map(([key, value]) => ({ key, bytes: Buffer.byteLength(JSON.stringify(value) ?? "null", "utf8"), kind: Array.isArray(value) ? `array[${value.length}]` : value === null ? "null" : typeof value })).sort((left, right) => right.bytes - left.bytes) : [];
404
+ return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, fetch: artifactFetchHint(id) }, transport);
405
+ }
406
+ if (options.keys !== undefined) {
407
+ // Round 15: tolerate a `result.`/`data.` prefix and report missing keys.
408
+ // bench63 tried `--keys result.publishedAttributesJson` (empty) before
409
+ // `--keys publishedAttributesJson` worked; the artifact content IS the
410
+ // result object, so a leading envelope prefix is a no-op the caller does
411
+ // not expect. Strip it, and when a key truly is absent list the real
412
+ // top-level keys so the next call is correct.
413
+ const wanted = options.keys.split(",").map((key) => key.trim()).filter(Boolean);
414
+ const record = content && typeof content === "object" && !Array.isArray(content) ? content : undefined;
415
+ const projected = {};
416
+ const missing = [];
417
+ for (const raw of wanted) {
418
+ const candidates = record === undefined ? [raw] : [raw, raw.replace(/^(result|data)\./, "")];
419
+ const hit = candidates.find((key) => record !== undefined && key in record);
420
+ if (hit !== undefined && record !== undefined)
421
+ projected[hit] = record[hit];
422
+ else
423
+ missing.push(raw);
424
+ }
425
+ const result = { status: "ok", artifactId: id, sha256: artifact.sha256, keys: wanted, ...(missing.length > 0 ? { missingKeys: missing, availableKeys: record ? Object.keys(record) : [] } : {}), content: projected };
426
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") > transport.maxOutputBytes)
427
+ 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);
428
+ return emitTransportResult(result, transport);
429
+ }
430
+ if (options.jq !== undefined) {
431
+ const projected = projectJsonPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
432
+ return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: projected === undefined ? null : projected }, transport);
433
+ }
434
+ const result = { status: "ok", artifactId: id, sha256: artifact.sha256, content };
435
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") > transport.maxOutputBytes)
436
+ return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, fetch: artifactFetchHint(id) }, transport);
437
+ return emitTransportResult(result, transport);
438
+ }
439
+ catch (error) {
440
+ // Round 16 (bench67 feedback): a missing/expired artifact id is ordinary
441
+ // caller error, not an internal fault — classify it as invalid-input with a
442
+ // structured code and a recovery hint instead of a generic `internal`.
443
+ const message = error instanceof Error ? error.message : String(error);
444
+ if (message === "artifact not found" || message === "invalid artifact id") {
445
+ const hint = message === "artifact not found"
446
+ ? "artifact 不存在或已被清理/过期;用产出它的命令返回的最新 artifactId(结果里的 fetch 提示)重新取,或重跑该命令"
447
+ : "artifactId 不是合法 UUID(36 位十六进制/连字符)";
448
+ const parsed = errorEnvelope(new Error(hint), "artifact-absent", CLI_EXIT_CODES.invalidInput, transport.request);
449
+ writeEnvelope(parsed.envelope);
450
+ return parsed.exitCode;
451
+ }
452
+ const parsed = errorEnvelope(error, "internal", CLI_EXIT_CODES.internal, transport.request);
453
+ writeEnvelope(parsed.envelope);
454
+ return parsed.exitCode;
455
+ }
456
+ }
457
+ async function cmdBench(args) {
458
+ const liveSample = args.includes("--live-sample");
459
+ const envIndex = args.indexOf("--env");
460
+ const liveEnvironment = envIndex >= 0 ? args[envIndex + 1] : undefined;
461
+ const { runBench, formatBenchReport } = await import("./bench/run.js");
462
+ const report = await runBench({ mode: liveSample ? "live-sample" : "fake", ...(liveEnvironment ? { liveEnvironment } : {}) });
463
+ console.error(formatBenchReport(report));
464
+ const failedSteps = report.steps.filter((step) => !step.ok).length;
465
+ return failedSteps === 0 && report.dimensions.intent.passed === report.dimensions.intent.cases ? 0 : 1;
466
+ }
467
+ function cmdDiscovery(kind, transport, value, options = {}) {
468
+ const registry = buildToolRegistry();
469
+ let result;
470
+ // Round 16b: `intent` is a CLI subcommand family, not a call tool, so
471
+ // `describe intent` used to dead-end. Route it (and `intent.schema`) to the
472
+ // offline DSL schema — the discovery face the dogfood rounds were missing.
473
+ if (kind === "describe" && (value === "intent" || value === "intent.schema" || value === "intent.validate")) {
474
+ // Honor `--example <id>` so `describe intent --example rule-actions-when-filter`
475
+ // returns runnable YAML, mirroring the tool-example UX.
476
+ if (options.example !== undefined && typeof options.example === "string") {
477
+ const found = intentExample(options.example);
478
+ if (!found)
479
+ return emitInvalid(transport, { message: `intent 示例 id 不存在:${options.example};可用示例:${intentExampleIds().join("、")}` });
480
+ return emitTransportResult({ status: "ok", ...found }, transport);
481
+ }
482
+ if (value === "intent.validate")
483
+ return emitTransportResult({ status: "ok", ...intentSchemaBlock(), note: "intent.validate 是子命令,不是工具:见 schema 的 commands;用法 omc intent validate <file>" }, transport);
484
+ return emitTransportResult({ status: "ok", ...intentSchemaBlock() }, transport);
485
+ }
486
+ if (kind === "commands") {
487
+ const scoped = options.filter !== undefined || options.lane !== undefined;
488
+ // Bare `commands` is the bounded index (see commandIndex): the full 54 KB
489
+ // catalogue must be requested with `commands --json` (round-9 heavy-task: an
490
+ // agent paid 4×54 KB on the default face before any hint could land).
491
+ if (options.json !== true && options.compact !== true && !scoped) {
492
+ result = commandIndex(registry);
493
+ return emitTransportResult({ status: "ok", ...result }, transport);
494
+ }
495
+ const inventory = commandInventory(registry, { compact: options.compact === true });
496
+ // `commands --json` is the programmatic selection entry: filtering server-side
497
+ // keeps the caller from pulling the whole >50 KB inventory to find one lane.
498
+ if (options.filter !== undefined || options.lane !== undefined) {
499
+ const tools = inventory.tools;
500
+ const keyword = options.filter?.toLowerCase();
501
+ const matched = tools.filter((tool) => {
502
+ if (options.lane !== undefined && tool.lane !== options.lane)
503
+ return false;
504
+ if (keyword === undefined)
505
+ return true;
506
+ return `${tool.name} ${tool.lane ?? ""} ${JSON.stringify(tool.capability ?? "")}`.toLowerCase().includes(keyword);
507
+ });
508
+ inventory.filtered = { filter: options.filter ?? null, lane: options.lane ?? null, matched: matched.length, total: tools.length };
509
+ inventory.tools = matched;
510
+ // A filtered query wants tool rows, not the whole catalogue's discovery,
511
+ // skills, and intent prose. Keep only the authoritative route pointer.
512
+ delete inventory.skills;
513
+ delete inventory.intent;
514
+ const discovery = inventory.discovery;
515
+ if (discovery)
516
+ inventory.discovery = { authoritative: discovery.authoritative, route: discovery.route };
517
+ }
518
+ result = inventory;
519
+ }
520
+ else if (kind === "contract") {
521
+ if (options.tool !== undefined) {
522
+ if (options.full)
523
+ return emitInvalid(transport, { message: "--tool and --full are mutually exclusive: --tool returns one tool's slice; --full returns the whole lane contract" });
524
+ const sliced = describeContractTool(registry, value ?? "", options.tool);
525
+ if (!sliced)
526
+ return emitInvalid(transport, { message: `contract target not found: lane ${value ?? ""}` });
527
+ const contract = sliced.contract;
528
+ if (contract.found === false)
529
+ return emitInvalid(transport, { message: `lane ${value ?? ""} has no tool "${options.tool}"; available: ${contract.available.join("、")}` });
530
+ result = sliced;
531
+ }
532
+ else {
533
+ result = options.full ? describeContractFull(registry, value ?? "") : describeContract(registry, value ?? "");
534
+ }
535
+ }
536
+ else if (options.schemaOnly)
537
+ result = describeSchemaOnly(registry, value ?? "");
538
+ else if (options.param)
539
+ result = describeParam(registry, value ?? "", options.param);
540
+ else if (options.example !== undefined)
541
+ result = describeExample(registry, value ?? "", typeof options.example === "string" ? options.example : undefined);
542
+ else if (options.full)
543
+ result = describeToolFull(registry, value ?? "");
544
+ else
545
+ result = describeTool(registry, value ?? "");
546
+ if (!result) {
547
+ // Name the closest candidates so a near-miss tool name does not cost a
548
+ // guess-and-retry call (round-4 dogfood: `form.create` / `data.create` /
549
+ // `model.list` each burned a call before the agent found the real tool).
550
+ const requested = value ?? "";
551
+ // Runtime approval has no dedicated tool: an agent asking for
552
+ // `workitem.approve` / `workflow.start` / `workitem.submit` dead-ends on the
553
+ // nearest tool and burns a round. Name the real path (round 15 dogfood).
554
+ const approvalIntent = /^(workflow|workitem)\.(start|approve|reject|submit|pass|complete|workitem)/i.test(requested) || /approv/i.test(requested);
555
+ const approvalPointer = approvalIntent
556
+ ? `;运行时审批/流程推进没有独立工具——查待办用 \`omc call workitem.list\`(拿 workItemId/workflowInstanceId/bizObjectId),提交/审批用 \`omc call data.submit\`(formType=1 + workItemId + workflowInstanceId + bizObjectId + formData;同意传 agree:true)。data.submit 成功后 readback.nextSubmit 会给出下一步审批参数(若仍有待办);逐级审批无需重复调用 workitem.list。示例形状:describe data.submit --example workflow-backed-data-submit`
557
+ : "";
558
+ // `intent.*` is a CLI subcommand namespace, not a tool: an agent asking for
559
+ // `describe intent.validate` dead-ends. Point at the real subcommands.
560
+ const intentPointer = /^intent[._\/]/i.test(requested)
561
+ ? `;intent 是 CLI 子命令,不是 call 工具——用 \`omc intent validate <file> [--show-intent]\`、\`omc intent plan <file> --env <env>\`、\`omc intent apply <planFile>\`、\`omc intent readback <file> --env <env>\`(用法见 omc help omc-start-here --section "声明式意图")`
562
+ : "";
563
+ // `form.get_published` / `form.draft_get` are natural guesses but there is ONE
564
+ // form read face: `form.get` (its result carries both draft and published
565
+ // faces; `published:true` narrows). Name it so the guess costs no retry.
566
+ const formPointer = /^form\./i.test(requested) && requested !== "form.get"
567
+ ? `;表单只有 \`form.get\` 一个读面:结果同时含 draft/published(publishedAttributesJson 等);要只看发布态用 \`omc call form.get --args '{"schemaCode":"…","published":true}'\``
568
+ : "";
569
+ // Accept a hyphen/underscore alias for a dotted namespace too: a subagent that
570
+ // asks for `describe rule-graph` (a methodology/template name, not a tool)
571
+ // should at least get the real `rule.*` tools back instead of a dead end.
572
+ const segment = requested.split(/[.\-_/]/)[0] ?? requested;
573
+ const candidates = registry
574
+ .map((tool) => tool.name)
575
+ .filter((name) => name === segment || name.startsWith(`${segment}.`) || name.includes(requested))
576
+ .slice(0, 8);
577
+ const pointer = candidates.length === 0 && (kind === "describe" || kind === "contract")
578
+ ? `;describe 的目标是工具名(如 rule.save);图/模板/节点形状用 describe <tool> --full 或 contract <lane>,工具清单用 omc next "<目标>" / omc commands --json --compact`
579
+ : "";
580
+ const absence = absencePointer(requested);
581
+ return emitInvalid(transport, { message: `${kind} target not found: "${requested}"${candidates.length > 0 ? `; did you mean: ${candidates.join("、")}` : ""}${absence}${approvalPointer}${intentPointer}${formPointer}${pointer}` });
582
+ }
583
+ return emitTransportResult({ status: "ok", ...result }, transport);
584
+ }
585
+ function cmdUsage(transport) {
586
+ return emitTransportResult(usageReport(), transport);
587
+ }
588
+ /**
589
+ * Round 3, module A — `omc next "<goal>"`: the goal → route solver. Read-only,
590
+ * zero environment contact; a deterministic projection of authored metadata.
591
+ */
592
+ function cmdNext(goal, transport) {
593
+ const result = goalRoute(buildToolRegistry(), goal);
594
+ return emitTransportResult(result, transport);
595
+ }
596
+ /**
597
+ * Ticket 125 — `omc capabilities` / `omc capability <id>`: the Configuration
598
+ * capability overview. Read-only, zero environment contact; a projection of
599
+ * the tool registry, the coverage manifest, and the intent capability registry.
600
+ */
601
+ function cmdCapabilities(transport) {
602
+ const result = compactCapabilities(buildToolRegistry());
603
+ return emitTransportResult({ status: "ok", ...result }, transport);
604
+ }
605
+ function cmdCapability(id, transport, executable = false) {
606
+ const registry = buildToolRegistry();
607
+ const resolved = resolveCapabilityId(id);
608
+ if (!resolved) {
609
+ const available = buildCapabilityOverview(registry).map((entry) => entry.id).join("、");
610
+ return emitInvalid(transport, { message: `未知 capability "${id}";合法取值:${available}(清单见 omc capabilities)` });
611
+ }
612
+ if (executable) {
613
+ const bundle = executableCapabilityBundle(registry, resolved);
614
+ if (!bundle)
615
+ return emitInvalid(transport, { message: `capability "${id}" 没有可执行 bundle` });
616
+ return emitResult({ status: "ok", capability: bundle }, transport);
617
+ }
618
+ return emitResult({ status: "ok", capability: capabilityDetail(registry, resolved) }, transport);
619
+ }
620
+ /**
621
+ * Read + validate an intent file, or emit the shared invalid-input envelope.
622
+ * The intent commands (validate / plan / readback) share this preamble.
623
+ */
624
+ function readIntentOrInvalid(file, transport) {
625
+ let outcome;
626
+ try {
627
+ outcome = validateIntentText(readIntentFile(intentConfigRoot(), file));
628
+ }
629
+ catch (error) {
630
+ return { kind: "exit", code: emitInvalid(transport, { message: error instanceof Error ? error.message : String(error) }) };
631
+ }
632
+ if (!outcome.valid || outcome.intent === undefined) {
633
+ for (const error of outcome.errors)
634
+ console.error(`intent ${file}:${error.line}:${error.column} [${error.code}] ${error.message}`);
635
+ writeEnvelope(envelope("error", { status: "error", valid: false, errors: outcome.errors }, transport.request, {
636
+ code: "invalid-input",
637
+ message: outcome.errors[0]?.message ?? "intent invalid",
638
+ retryable: false,
639
+ }));
640
+ return { kind: "exit", code: CLI_EXIT_CODES.invalidInput };
641
+ }
642
+ return { kind: "ok", intent: outcome.intent };
643
+ }
644
+ /**
645
+ * Compact confirmation of a valid intent: which models/fields/rules the DSL
646
+ * normalized to, WITHOUT echoing the whole parsed intent (round 15). The caller
647
+ * authored the file, so repeating every name/type/option back is redundant —
648
+ * bench45 charged ~3.3 KB × 7 validate calls. `--show-intent` restores the full
649
+ * echo when a caller genuinely wants the normalized shape.
650
+ */
651
+ function intentDigest(intent) {
652
+ return {
653
+ intentVersion: intent.intentVersion,
654
+ models: intent.models.map((model) => ({
655
+ code: model.code,
656
+ ...(model.absent ? { absent: true } : {}),
657
+ fieldCodes: model.fields.map((field) => field.code),
658
+ ...(model.fields.some((field) => field.subFields !== undefined)
659
+ ? { subFieldCodes: Object.fromEntries(model.fields.filter((field) => field.subFields !== undefined).map((field) => [field.code, (field.subFields ?? []).map((sub) => sub.code)])) }
660
+ : {}),
661
+ ...(model.datarules !== undefined ? { datarulePropertyCodes: model.datarules.map((rule) => rule.propertyCode) } : {}),
662
+ ...(model.rules !== undefined ? { ruleCodes: model.rules.map((rule) => rule.code) } : {}),
663
+ ...(model.form !== undefined ? { form: true } : {}),
664
+ ...(model.listview !== undefined ? { listview: true } : {}),
665
+ ...(model.workflow !== undefined ? { workflow: true } : {}),
666
+ ...(model.onlinejs !== undefined ? { onlinejs: true } : {}),
667
+ })),
668
+ ...(intent.services !== undefined ? { serviceCodes: intent.services.map((service) => service.code) } : {}),
669
+ ...(intent.reports !== undefined ? { reportCodes: intent.reports.map((report) => report.code) } : {}),
670
+ };
671
+ }
672
+ /**
673
+ * Warning-severity findings the declarative face must not swallow. The rule
674
+ * dry-lint computes warnings (e.g. `rule-graph-cross-model-unverified`: a
675
+ * cross-model UPDATE saves+publishes but was observed to write nothing at
676
+ * runtime — bench50/52/53/55); `validateRuleNodeGraph` is the single source, so
677
+ * this reuses it rather than re-deriving semantics.
678
+ */
679
+ function intentWarnings(intent) {
680
+ const warnings = [];
681
+ for (const model of intent.models) {
682
+ // Field-level data rules of type 3/4/5 (拼接/必填/只读) are PORTAL-FACING, but
683
+ // the platform stores them as bizdatarule rows and mirrors them only into the
684
+ // form control's `historyOpt` — while the runtime portal reads the control's
685
+ // `options`. So a datarule-only required/readonly renders/stays inert in the
686
+ // portal (live 2026-09-28: bench62 datarule type4 on order_note, published
687
+ // control had no requiredFormula; the live portal did not require it). The
688
+ // portal-facing condition must be declared in `fields[].options`.
689
+ for (const datarule of model.datarules ?? []) {
690
+ if ([3, 4, 5].includes(datarule.dataRuleType))
691
+ warnings.push({
692
+ resource: `datarule:${model.code}.${datarule.propertyCode}`,
693
+ code: "datarule-not-portal-facing",
694
+ message: `字段级数据规则 type ${datarule.dataRuleType}(${datarule.dataRuleType === 4 ? "条件必填" : datarule.dataRuleType === 5 ? "条件只读" : "文本拼接"})不驱动门户表单控件:平台把它存成 bizdatarule、只镜像进控件 historyOpt,而运行时门户读控件 options(真机复核:规则已存但发布控件无该字段、门户不拦)。要让门户生效,把条件写到字段的 options 里(fields[].options.requiredFormula / readonlyCondition / shortTextStitch)——这条路径已真机验证可穿透到发布控件;datarule 只留给服务端字段级引擎(校验/计算)。`,
695
+ });
696
+ // Dual-write advisory (dogfood R1): when the SAME field already declares
697
+ // the portal-facing condition in `fields[].options`, the datarule row is
698
+ // redundant. Surface it rather than silently writing both.
699
+ const field = model.fields.find((candidate) => candidate.code === datarule.propertyCode);
700
+ const fieldOptions = field?.options ?? {};
701
+ const duplicateKey = datarule.dataRuleType === 4 ? "requiredFormula" : datarule.dataRuleType === 5 ? "readonlyCondition" : undefined;
702
+ if (duplicateKey !== undefined && fieldOptions[duplicateKey] !== undefined)
703
+ warnings.push({
704
+ resource: `datarule:${model.code}.${datarule.propertyCode}`,
705
+ code: "datarule-duplicates-field-options",
706
+ message: `字段 ${model.code}.${datarule.propertyCode} 同时在 fields[].options.${duplicateKey} 和 datarules[](type ${datarule.dataRuleType})声明了同一条件——门户只读 options,datarule 那条是冗余(服务端字段级引擎用)。若要门户生效保留 options 即可;datarule 仅在需要服务端强制时才必须保留。`,
707
+ });
708
+ }
709
+ for (const rule of model.rules ?? []) {
710
+ // Preset trigger rules are platform-managed: their `name` is a platform
711
+ // default (数据新增/数据更新/…). `rule.save` keeps the stored name for these
712
+ // codes, so a declared `name` is ignored — say so rather than silently
713
+ // dropping it (a caller who meant to rename would otherwise be confused).
714
+ if (PLATFORM_TRIGGER_RULE_CODES.has(rule.code))
715
+ warnings.push({
716
+ resource: `rule:${model.code}.${rule.code}`,
717
+ code: "rule-preset-name-ignored",
718
+ message: `规则 ${rule.code} 是平台预置触发规则,其名称由平台维护(如 Create=数据新增);声明的 name "${rule.name}" 不会生效(只有自定义规则的名称与节点名可改)。要改的是节点名,请改 nodeGraph/actions 中节点的 nodeName。`,
719
+ });
720
+ let graph;
721
+ try {
722
+ graph = expectedRuleGraph(rule, model.code);
723
+ }
724
+ catch {
725
+ continue; // shape errors are surfaced by validate/plan already
726
+ }
727
+ // Round 16 (bench66): `filter` LOCATES the rows to update, so a same-model
728
+ // action whose filter uses only non-unique fields updates EVERY matching
729
+ // row, not just the submitted one. bench66 produced status=中额 for a
730
+ // 120000 order because the ladder's overlapping amount-filters each matched
731
+ // the one row and the last one won — and a real table would have had its
732
+ // other rows clobbered. Keep bulk expressible (warn, don't gate) but name
733
+ // both fixes: locate self with `filter: [{field:id, from:id}]`, or gate the
734
+ // action with `when:` (an execution-gate `dataCondition`).
735
+ for (const node of graph.nodes ?? []) {
736
+ if (String(node.nodeType ?? "") !== "UPDATE")
737
+ continue;
738
+ const target = String(node.targetObjectCode ?? "");
739
+ const sameModel = target === model.code || target.startsWith(`${model.code}.`);
740
+ if (!sameModel)
741
+ continue;
742
+ const leaves = [];
743
+ walkConditionLeaves(node.filterCondition, (leaf) => leaves.push(leaf));
744
+ if (leaves.length === 0)
745
+ continue; // no filter = default self-identity, safe
746
+ if (leaves.some((leaf) => String(leaf.targetSchemaDataItem) === "id"))
747
+ continue;
748
+ warnings.push({
749
+ resource: `rule:${model.code}.${rule.code}`,
750
+ code: "rule-locator-not-unique",
751
+ message: `规则 ${rule.code} 的 UPDATE 节点定位条件(filterCondition)未用唯一键 id:filter 是「定位要更新哪些行」,用非唯一字段(如金额/状态)会命中并更新**多条**记录(真机 bench66:金额分级误配导致多行互相覆盖)。只更新当前这条用 filter: [{field: id, from: id}](省略 filter 时同模型动作已默认按自身 id 定位);「输入数据满足条件才执行该节点」是执行门,用 when: [{field: <字段>, gt: <值>}](编译到 dataCondition),与 filter 是两码事。确属批量更新可忽略本告警。`,
752
+ });
753
+ }
754
+ for (const finding of validateRuleNodeGraph(graph, rule.code, model.code)) {
755
+ if (finding.severity === "warning")
756
+ warnings.push({ resource: `rule:${model.code}.${rule.code}`, code: finding.code, message: finding.message });
757
+ }
758
+ }
759
+ }
760
+ return warnings;
761
+ }
762
+ /** Ticket 117 — `omc intent validate <file>`: offline, zero environment contact. */
763
+ function cmdIntentValidate(file, transport, showIntent = false) {
764
+ const read = readIntentOrInvalid(file, transport);
765
+ if (read.kind === "exit")
766
+ return read.code;
767
+ const warnings = intentWarnings(read.intent);
768
+ return emitResult({ status: "ok", valid: true, errors: [], digest: intentDigest(read.intent), ...(warnings.length > 0 ? { warnings } : {}), ...(showIntent ? { intent: read.intent } : {}) }, transport);
769
+ }
770
+ /** Round 16b — `omc intent schema [--example <id>]`: the offline DSL discovery face. */
771
+ function cmdIntentSchema(transport, example) {
772
+ if (example !== undefined) {
773
+ const found = intentExample(example);
774
+ if (!found)
775
+ return emitInvalid(transport, {
776
+ message: `intent 示例 id 不存在:${example};可用示例:${intentExampleIds().join("、")}。用 \`omc intent schema\` 看完整 schema。`,
777
+ });
778
+ return emitResult(found, transport);
779
+ }
780
+ return emitResult(intentSchemaBlock(), transport);
781
+ }
782
+ /** Context-cost guard: an inline plan whose JSON exceeds this (or whose decoded baseline does) is externalized to a file (see cmdIntentPlan). A populated environment's baseline is ~53 KB; a rich greenfield intent's plan is ~20 KB — both charged on every later turn. */
783
+ const INTENT_PLAN_INLINE_CEILING_BYTES = 16384;
784
+ /** Ticket 118 — `omc intent plan <file> --env <env>`: read-only baseline -> compile -> immutable plan. */
785
+ async function cmdIntentPlan(file, environment, planOut, transport) {
786
+ const read = readIntentOrInvalid(file, transport);
787
+ if (read.kind === "exit")
788
+ return read.code;
789
+ const intent = read.intent;
790
+ // Validate the optional plan output path BEFORE any environment contact, so a
791
+ // bad path is a clean invalid-input (exit 10), never a wire attempt.
792
+ let outPath;
793
+ if (planOut !== undefined) {
794
+ try {
795
+ outPath = assertSafeOutputPath(intentConfigRoot(), planOut, "plan output file");
796
+ }
797
+ catch (error) {
798
+ return emitInvalid(transport, { message: error instanceof Error ? error.message : String(error) });
799
+ }
800
+ }
801
+ let result;
802
+ try {
803
+ const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
804
+ const session = await context.sessions.session(environment);
805
+ const baseline = await readBaseline(session, intent);
806
+ const compiled = compileIntent(intent, baseline);
807
+ const plan = buildPlan({
808
+ environment: session.environmentName,
809
+ environmentFingerprint: session.environmentFingerprint(),
810
+ compiler: INTENT_COMPILER_VERSION,
811
+ baseline,
812
+ intent,
813
+ compiled,
814
+ });
815
+ const planJson = JSON.stringify(plan, null, 2) + "\n";
816
+ const artifact = createArtifact(artifactRoot(), planJson);
817
+ const warnings = intentWarnings(intent);
818
+ const toolCounts = new Map();
819
+ for (const change of compiled.changes)
820
+ toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
821
+ const changeSummary = [...toolCounts.entries()].map(([tool, count]) => ({ tool, count }));
822
+ const changePhrase = changeSummary.map(({ tool, count }) => (count > 1 ? `${tool}×${count}` : tool)).join("、") || "无";
823
+ console.error(`intent plan ${plan.planHash.slice(0, 12)}:${compiled.changes.length} 项变更(${changePhrase}),${compiled.skipped.length} 项已达标跳过,风险=${compiled.riskClass};环境=${plan.environment}`);
824
+ if (planOut !== undefined) {
825
+ // Lean route (2026-09-27): the plan is the immutable approval object and
826
+ // its full JSON is ~11 KB for a real requirement — inlining it charges the
827
+ // agent's window on every later turn. Write the byte-identical plan to a
828
+ // file inside the config root (so `intent apply` can read it) and return
829
+ // only the decision fields + the path.
830
+ writeFileSync(outPath, planJson, { encoding: "utf8", mode: 0o600 });
831
+ result = {
832
+ status: "ok",
833
+ planId: plan.planHash.slice(0, 12),
834
+ planHash: plan.planHash,
835
+ // Echo the caller's own path string: an absolute host path would be
836
+ // redacted by the envelope, and the caller already knows the name.
837
+ planFile: planOut,
838
+ changesCount: compiled.changes.length,
839
+ skippedCount: compiled.skipped.length,
840
+ riskClass: compiled.riskClass,
841
+ changeSummary,
842
+ planArtifactId: artifact.artifactId,
843
+ planArtifactSha256: artifact.sha256,
844
+ nextAction: `omc intent apply ${planOut}`,
845
+ };
846
+ }
847
+ else {
848
+ // Context-cost guard (round 15): a populated environment's decoded
849
+ // baseline makes the inline plan 50–60 KB, and a rich greenfield intent's
850
+ // plan is ~20 KB — either is the single largest payload any read face
851
+ // emits, charged on every later turn. Above the ceiling, externalize the
852
+ // (byte-identical) immutable plan to the config root, which `intent apply`
853
+ // can read, and return only decision fields + the path. Small plans stay
854
+ // inline (deterministic on size).
855
+ const baselineBytes = jsonBytes(baseline);
856
+ const overCeiling = planJson.length > INTENT_PLAN_INLINE_CEILING_BYTES || baselineBytes > INTENT_PLAN_INLINE_CEILING_BYTES;
857
+ // If the config root is not writable, fall back to the inline plan (the
858
+ // pre-existing behaviour) rather than turning a read-only cwd into a
859
+ // failure.
860
+ let externalized = false;
861
+ let planFileName = "";
862
+ if (overCeiling) {
863
+ planFileName = `intent-plan-${plan.planHash.slice(0, 12)}.json`;
864
+ try {
865
+ writeFileSync(join(intentConfigRoot(), planFileName), planJson, { encoding: "utf8", mode: 0o600 });
866
+ externalized = true;
867
+ }
868
+ catch {
869
+ externalized = false;
870
+ }
871
+ }
872
+ if (externalized) {
873
+ result = {
874
+ status: "ok",
875
+ planId: plan.planHash.slice(0, 12),
876
+ planHash: plan.planHash,
877
+ planFile: planFileName,
878
+ changesCount: compiled.changes.length,
879
+ skippedCount: compiled.skipped.length,
880
+ riskClass: compiled.riskClass,
881
+ changeSummary,
882
+ planArtifactId: artifact.artifactId,
883
+ planArtifactSha256: artifact.sha256,
884
+ planExternalized: true,
885
+ ...(warnings.length > 0 ? { warnings } : {}),
886
+ note: `plan ${planJson.length}B(基线 ${baselineBytes}B)超过内联上限 ${INTENT_PLAN_INLINE_CEILING_BYTES}B,未内联,已写入 ${planFileName};直接 ${"`omc intent apply " + planFileName + "`"}`,
887
+ nextAction: `omc intent apply ${planFileName}`,
888
+ };
889
+ }
890
+ else {
891
+ result = {
892
+ status: "ok",
893
+ planId: plan.planHash.slice(0, 12),
894
+ planHash: plan.planHash,
895
+ changesCount: compiled.changes.length,
896
+ skippedCount: compiled.skipped.length,
897
+ riskClass: compiled.riskClass,
898
+ plan,
899
+ planArtifactId: artifact.artifactId,
900
+ planArtifactSha256: artifact.sha256,
901
+ ...(warnings.length > 0 ? { warnings } : {}),
902
+ };
903
+ }
904
+ }
905
+ }
906
+ catch (error) {
907
+ if (error instanceof ToolRefusalError)
908
+ result = error.payload;
909
+ else if (error instanceof WireError)
910
+ result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
911
+ else
912
+ result = { status: "error", message: error instanceof Error ? error.message : String(error) };
913
+ }
914
+ return emitResult(result, transport);
915
+ }
916
+ /**
917
+ * Spec 107 readback core: decode the authoritative live baseline back into
918
+ * intent shape and compare it to the source intent. Shared by `intent readback`
919
+ * (equivalent:false is a legitimate not-yet-applied state, exit 0) and, since
920
+ * ticket 123, by `intent apply` as the final confirmation step, where a
921
+ * mismatch must not be reported as plain success.
922
+ */
923
+ async function verifyIntentLive(session, intent) {
924
+ const baseline = await readBaseline(session, intent);
925
+ const live = decodeBaseline(baseline, intent);
926
+ const report = compareWithLive(intent, live);
927
+ return {
928
+ environment: session.environmentName,
929
+ environmentFingerprint: session.environmentFingerprint(),
930
+ baselineFingerprint: baselineFingerprintOf(baseline),
931
+ equivalent: report.equivalent,
932
+ differences: report.differences,
933
+ ignoredOutsideIntent: report.ignoredOutsideIntent,
934
+ live,
935
+ };
936
+ }
937
+ /**
938
+ * Route the decoded `live` blob to an artifact once it exceeds the inline
939
+ * ceiling, keeping only the decision fields (equivalent / differences /
940
+ * counts). Real requirements decode to tens of KB: round 9 (sandbox 43) an
941
+ * `intent readback` was 62 KB × ~10 calls because it inlined the full live
942
+ * state, and the agent redirected each to a file to avoid the context cost.
943
+ * Same diet as governedRead's full ceiling (src/read.ts) and the same
944
+ * reachability guarantee: the blob stays fully available via the artifact.
945
+ */
946
+ const INTENT_LIVE_INLINE_CEILING_BYTES = 8192;
947
+ /**
948
+ * Context-cost (round 15): `ignoredOutsideIntent` lists every resource outside
949
+ * the intent (ownership-ignored, never a difference). A real model carries ~40
950
+ * system fields, so a 3-model intent reported 212 entries = ~5.7 KB of pure
951
+ * noise on every readback — twice per task. Inline a bounded sample plus the
952
+ * true count; the list is informational, so nothing decision-making is lost.
953
+ */
954
+ const INTENT_IGNORED_INLINE_LIMIT = 20;
955
+ function capIgnoredOutsideIntent(verification) {
956
+ const ignored = verification.ignoredOutsideIntent;
957
+ if (!Array.isArray(ignored) || ignored.length <= INTENT_IGNORED_INLINE_LIMIT)
958
+ return {};
959
+ return {
960
+ ignoredOutsideIntent: ignored.slice(0, INTENT_IGNORED_INLINE_LIMIT),
961
+ ignoredOutsideIntentCount: ignored.length,
962
+ ignoredOutsideIntentHint: `意图外资源共 ${ignored.length} 项(按所有权忽略,不是差异);此处只内联前 ${INTENT_IGNORED_INLINE_LIMIT} 项`,
963
+ };
964
+ }
965
+ function dietIntentLive(verification, context) {
966
+ const capped = capIgnoredOutsideIntent(verification);
967
+ const live = verification.live;
968
+ if (live === undefined)
969
+ return { ...verification, ...capped };
970
+ const bytes = jsonBytes(live);
971
+ if (bytes <= INTENT_LIVE_INLINE_CEILING_BYTES)
972
+ return { ...verification, ...capped };
973
+ const { live: _omitted, ...rest } = verification;
974
+ const ref = writeArtifact(context, `intent-live-${sha256(JSON.stringify(live)).slice(0, 16)}.json`, live);
975
+ return {
976
+ ...rest,
977
+ ...capped,
978
+ liveOmitted: true,
979
+ liveBytes: bytes,
980
+ liveArtifactHint: `权威 live 状态 ${bytes}B 超过内联上限 ${INTENT_LIVE_INLINE_CEILING_BYTES}B,已外置;上面的 equivalent/differences 已是决策所需,需要原生状态用 \`omc artifact get <detailArtifactId>\`(可 --jq <path>)定向取回`,
981
+ detailFile: ref.detailFile,
982
+ detailSha256: ref.detailSha256,
983
+ };
984
+ }
985
+ /**
986
+ * Ticket 120 — `omc intent readback <file> --env <env>`: decode the live
987
+ * baseline back into intent shape and verify equivalence. equivalent:false
988
+ * stays exit 0 — it is a legitimate not-yet-applied state the agent decides
989
+ * on (re-plan is the repair path).
990
+ */
991
+ async function cmdIntentReadback(file, environment, transport) {
992
+ const read = readIntentOrInvalid(file, transport);
993
+ if (read.kind === "exit")
994
+ return read.code;
995
+ const intent = read.intent;
996
+ let result;
997
+ try {
998
+ const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
999
+ const session = await context.sessions.session(environment);
1000
+ const verification = dietIntentLive(await verifyIntentLive(session, intent), context);
1001
+ const equivalent = verification.equivalent === true;
1002
+ const ignoredCount = Number(verification.ignoredOutsideIntentCount ?? verification.ignoredOutsideIntent?.length ?? 0);
1003
+ console.error(`intent readback:${equivalent ? "等价 ✓" : `${verification.differences.length} 项差异`}${ignoredCount > 0 ? `(意图外资源 ${ignoredCount} 项按所有权忽略)` : ""}`);
1004
+ result = { status: "ok", ...verification };
1005
+ }
1006
+ catch (error) {
1007
+ if (error instanceof ToolRefusalError)
1008
+ result = error.payload;
1009
+ else if (error instanceof WireError)
1010
+ result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
1011
+ else
1012
+ result = { status: "error", message: error instanceof Error ? error.message : String(error) };
1013
+ }
1014
+ return emitResult(result, transport);
1015
+ }
1016
+ /**
1017
+ * Ticket 119 — `omc intent apply <plan-file>`: plan integrity + baseline
1018
+ * drift gates, then serial execution through the EXISTING apply_changes
1019
+ * machinery (one master approval, per-step readback, first-failure stop).
1020
+ * No write logic is forked here — the compiled changes drive the same
1021
+ * governed pipeline `omc call` uses.
1022
+ */
1023
+ async function cmdIntentApply(planPath, options, transport) {
1024
+ const emit = (result) => emitResult(result, transport);
1025
+ const invalid = (blocker, message, code = "invalid-input") => emitInvalid(transport, { message, blocker, code });
1026
+ let planText;
1027
+ try {
1028
+ planText = readIntentFile(intentConfigRoot(), planPath);
1029
+ }
1030
+ catch (fileError) {
1031
+ // The plan for a large intent is externalized: `intent plan` returns
1032
+ // `planFile` AND an artifactId. Callers read the id in the result and try
1033
+ // `intent apply <artifactId>`, which used to fail with a bare "intent file
1034
+ // does not exist" (bench40). Accept the artifact id directly, and otherwise
1035
+ // name both recovery paths.
1036
+ try {
1037
+ planText = readArtifact(artifactRoot(), planPath).content.toString("utf8");
1038
+ }
1039
+ catch {
1040
+ const hint = planPath.endsWith(".json") || planPath.includes("/") || planPath.includes("\\")
1041
+ ? "plan 文件不存在或不在配置根内;plan 产物里有 planFile/artifactId 两种引用,直接给 planFile 或 artifactId 均可"
1042
+ : `plan 找不到 "${planPath}"。大 plan 会外置——用 intent plan 结果里的 planFile 路径或 artifactId(可直接 \`omc intent apply <artifactId>\`);小 plan 内联在 result.plan,先落成 .json 再传入`;
1043
+ return invalid(undefined, `${hint}(原错误:${fileError instanceof Error ? fileError.message : String(fileError)})`);
1044
+ }
1045
+ }
1046
+ let parsed;
1047
+ try {
1048
+ parsed = JSON.parse(planText);
1049
+ }
1050
+ catch (error) {
1051
+ return invalid(undefined, `plan 文件不是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
1052
+ }
1053
+ const verified = verifyPlan(parsed);
1054
+ if (!verified.ok)
1055
+ return invalid(verified.code, verified.reason);
1056
+ const plan = verified.plan;
1057
+ const planId = plan.planHash.slice(0, 12);
1058
+ if (options.env !== undefined && options.env !== plan.environment)
1059
+ return invalid("intent-plan-environment-mismatch", `--env ${options.env} 与 plan 环境不一致(plan 属于 ${plan.environment};plan 是不可变审批对象,不跨环境迁移)`);
1060
+ if (options.dryRun === true) {
1061
+ const toolCounts = new Map();
1062
+ for (const change of plan.changes)
1063
+ toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
1064
+ console.error(`intent apply ${planId} --dry-run:${plan.changes.length} 项变更预览,零环境写入`);
1065
+ return emit({
1066
+ status: "dry-run",
1067
+ wouldChange: plan.changes.length > 0,
1068
+ planId,
1069
+ changes: plan.changes,
1070
+ skippedCount: plan.skipped.length,
1071
+ riskClass: plan.riskClass,
1072
+ preview: [...toolCounts.entries()].map(([tool, count]) => (count > 1 ? `${tool}×${count}` : tool)).join("、") || "无变更",
1073
+ });
1074
+ }
1075
+ // The whole apply holds the same cross-process write lock `omc call`
1076
+ // acquires per write — one writer per environment across every face.
1077
+ const lock = await acquireWriteLock(plan.environment);
1078
+ if (!lock.held) {
1079
+ const conflict = {
1080
+ status: "refused",
1081
+ blocker: "write-lock-held",
1082
+ environment: plan.environment,
1083
+ owner: lock.owner,
1084
+ recovery: lock.recovery,
1085
+ message: lock.recovery[0],
1086
+ };
1087
+ console.error(`write lock conflict: ${JSON.stringify(lock.owner)}`);
1088
+ return emit(conflict);
1089
+ }
1090
+ try {
1091
+ const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
1092
+ const session = await context.sessions.session(plan.environment);
1093
+ if (session.environmentFingerprint() !== plan.environmentFingerprint)
1094
+ return emit({
1095
+ status: "refused",
1096
+ blocker: "intent-plan-environment-mismatch",
1097
+ environment: session.environmentName,
1098
+ message: "环境指纹与 plan 不一致——plan 不跨环境迁移;在目标环境重新 intent plan",
1099
+ });
1100
+ // Baseline drift gate (CONTEXT.md Configuration baseline): a fingerprint
1101
+ // change after planning invalidates the plan and requires replanning.
1102
+ const currentBaseline = await readBaseline(session, plan.intent);
1103
+ if (baselineFingerprintOf(currentBaseline) !== plan.baselineFingerprint)
1104
+ return emit({
1105
+ status: "refused",
1106
+ blocker: "intent-baseline-drifted",
1107
+ environment: session.environmentName,
1108
+ message: "plan 之后环境已变化(baseline fingerprint 不一致)——按配置基线契约,写入前必须重新 intent plan;绝不盲用过期 plan",
1109
+ });
1110
+ // Ticket 123 — the final confirmation: after the writes (or on an
1111
+ // already-converged no-op) decode the authoritative live state back into
1112
+ // intent shape and compare. A mismatch is NEVER reported as plain success
1113
+ // (blocker intent-readback-mismatch + refusal exit); it means delivery is
1114
+ // incomplete or drifted and the agent must re-plan, not retry blindly.
1115
+ const confirmReadback = async (base) => {
1116
+ if (options.verify === false)
1117
+ return base;
1118
+ let verification;
1119
+ try {
1120
+ const report = await verifyIntentLive(session, plan.intent);
1121
+ verification = { ...dietIntentLive(report, context), status: report.equivalent === true ? "ok" : "verification-failed" };
1122
+ }
1123
+ catch (error) {
1124
+ verification = { status: "error", equivalent: false, error: error instanceof Error ? error.message : String(error) };
1125
+ }
1126
+ if (verification.equivalent === true) {
1127
+ console.error(`intent apply ${planId}:最终读回等价 ✓(意图 ${plan.intent.models.length} 个模型)`);
1128
+ return { ...base, verification };
1129
+ }
1130
+ console.error(`intent apply ${planId}:最终读回不等价——${Array.isArray(verification.differences) ? `${verification.differences.length} 项差异` : String(verification.error ?? "读回失败")};不得当作成功`);
1131
+ return {
1132
+ ...base,
1133
+ status: "refused",
1134
+ blocker: "intent-readback-mismatch",
1135
+ verification,
1136
+ message: "变更已执行,但最终权威读回与意图不等价:交付可能不完整或环境已漂移。按 differences 定位后重新 intent plan(不要盲重试)",
1137
+ nextAction: "inspect verification.differences, then re-run intent plan against the live baseline",
1138
+ };
1139
+ };
1140
+ if (plan.changes.length === 0) {
1141
+ console.error(`intent apply ${planId}:零变更(环境已达标),无审批无写入`);
1142
+ return emit(await confirmReadback({ status: "ok", planId, intentHash: plan.intentHash, applied: 0, skipped: plan.skipped, message: "环境已达标:零变更零审批" }));
1143
+ }
1144
+ const registry = buildToolRegistry();
1145
+ const applyChanges = registry.find((definition) => definition.name === "apply_changes");
1146
+ if (!applyChanges)
1147
+ return emit({ status: "error", message: "apply_changes 工具不可用" });
1148
+ const changes = plan.changes.map((change) => ({ tool: change.tool, args: change.args }));
1149
+ const toolCounts = new Map();
1150
+ for (const change of changes)
1151
+ toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
1152
+ const summary = `intent apply ${planId}:${changes.length} 项变更(${[...toolCounts.entries()].map(([tool, count]) => (count > 1 ? `${tool}×${count}` : tool)).join("、")})`;
1153
+ let result;
1154
+ try {
1155
+ result = (await applyChanges.handler(context, parseToolInput(applyChanges, {
1156
+ changes,
1157
+ environment: plan.environment,
1158
+ summary,
1159
+ ...(options.confirmProduction === true ? { confirmProduction: true } : {}),
1160
+ }), applyChanges));
1161
+ }
1162
+ catch (error) {
1163
+ if (error instanceof ToolInputError)
1164
+ result = error.payload;
1165
+ else if (error instanceof ToolRefusalError)
1166
+ result = error.payload;
1167
+ else if (error instanceof WireError)
1168
+ result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
1169
+ else
1170
+ result = { status: "error", message: error instanceof Error ? error.message : String(error) };
1171
+ }
1172
+ if (result.blocker === "production-confirmation-required")
1173
+ console.error(`intent apply ${planId}:目标环境是生产环境——确认这是高危操作后,用 --confirm-production 重跑 apply(一次确认覆盖全部 ${changes.length} 步)`);
1174
+ else
1175
+ console.error(`intent apply ${planId}:${String(result.status)}(${changes.length} 项变更)`);
1176
+ const applied = Array.isArray(result.steps) ? result.steps.length : undefined;
1177
+ // Context-cost (round 15): the intent ladder's per-step `performed`/`readback`
1178
+ // is redundant with the final intent readback-equivalence (the authority), and
1179
+ // for a complex model it is ~17 KB of the envelope (listview columns, field
1180
+ // publishCodes, datarule rows). Emit the step IDENTITY only; the full per-step
1181
+ // bodies stay in the package/progress artifact (result.readback.progressFile).
1182
+ const steps = Array.isArray(result.steps)
1183
+ ? result.steps.map((step) => ({ step: step.step, index: step.index, status: step.status }))
1184
+ : result.steps;
1185
+ const delivered = { ...result, steps, planId, intentHash: plan.intentHash, ...(applied !== undefined && result.status === "ok" ? { applied } : {}) };
1186
+ // Only a genuinely delivered package earns the final readback; a partial /
1187
+ // refused / uncertain outcome already reports its own status and must not
1188
+ // be papered over by a second read.
1189
+ return emit(result.status === "ok" ? await confirmReadback(delivered) : delivered);
1190
+ }
1191
+ finally {
1192
+ await lock.release();
1193
+ }
1194
+ }
1195
+ /**
1196
+ * Ticket 122 — `omc help [topic]`: the methodology layer, reachable exactly
1197
+ * like `git help <concept>`. The command list is the "what"; these topics are
1198
+ * the "how".
1199
+ */
1200
+ function cmdHelp(topic, commandNames, transport, section, sections = false) {
1201
+ const topics = listSkills();
1202
+ const commands = [...commandNames].sort();
1203
+ // Several sections in one call: `--section a --section b` or `--section a,b`.
1204
+ // Round 15: bench60-62 each spent ~18 separate `help --section` round-trips
1205
+ // (one per heading the route listed). Fetching them together collapses those
1206
+ // turns without changing the bytes the caller needs. A bare `--section` (no
1207
+ // value) still means "list this topic's sections".
1208
+ const bareSection = Array.isArray(section) ? section.includes(true) : section === true || section === "";
1209
+ const requestedSections = Array.isArray(section)
1210
+ ? section.filter((value) => typeof value === "string").flatMap((value) => value.split(",")).map((value) => value.trim()).filter((value) => value.length > 0)
1211
+ : typeof section === "string" && section.trim().length > 0
1212
+ ? section.split(",").map((value) => value.trim()).filter((value) => value.length > 0)
1213
+ : [];
1214
+ // `intent` (and `call`, `commands`, …) are COMMANDS, not methodology topics; a
1215
+ // `help intent` dead-ends. Point at the real entry (round 15 dogfood).
1216
+ const commandPointer = topic !== undefined && !topics.some((entry) => entry.name === topic)
1217
+ ? (/^intent$/i.test(topic)
1218
+ ? ";`intent` 是 CLI 子命令,不是方法论 topic——用 `omc intent --help` 看子命令,或 `omc help omc-start-here --section \"声明式意图\"`"
1219
+ : commands.includes(topic)
1220
+ ? `;\`${topic}\` 是 CLI 命令,不是方法论 topic——用 \`omc ${topic} --help\` 看用法`
1221
+ : "")
1222
+ : "";
1223
+ if (topic === undefined) {
1224
+ console.error("用法:omc help <topic>");
1225
+ console.error("权威路线:omc help omc-start-here(随包 skills/,不是分发镜像)");
1226
+ console.error("环境就绪:omc call discover_environment --env <env> --args '{}'");
1227
+ console.error(`命令:${commands.join("、")}`);
1228
+ console.error(`方法论 topic:${topics.map((entry) => entry.name).join("、")}`);
1229
+ return emitTransportResult({
1230
+ status: "ok",
1231
+ usage: "omc help <topic>",
1232
+ discovery: DISCOVERY_AUTHORITY,
1233
+ commands,
1234
+ topics,
1235
+ }, transport);
1236
+ }
1237
+ if (topic === "commands") {
1238
+ console.error(`命令 ${commands.length} 项`);
1239
+ return emitTransportResult({ status: "ok", commands }, transport);
1240
+ }
1241
+ if (topic === "topics") {
1242
+ console.error(`方法论 topic ${topics.length} 项`);
1243
+ return emitTransportResult({ status: "ok", topics }, transport);
1244
+ }
1245
+ if (requestedSections.length > 1) {
1246
+ const document = topic === undefined ? undefined : readSkill(topic);
1247
+ if (!document)
1248
+ return emitInvalid(transport, { message: `未知 topic "${topic ?? ""}";先用 omc help topics 查看可用 topic` });
1249
+ const found = [];
1250
+ const missing = [];
1251
+ const headings = readSkillSection(topic, "__not-a-real-heading__")?.headings ?? [];
1252
+ for (const requested of requestedSections) {
1253
+ const sliced = readSkillSection(topic, requested);
1254
+ if (sliced?.section)
1255
+ found.push({ section: requested, content: sliced.section });
1256
+ else
1257
+ missing.push(requested);
1258
+ }
1259
+ if (found.length === 0)
1260
+ return emitInvalid(transport, { message: `topic ${topic} 没有 section ${missing.map((value) => `"${value}"`).join("、")};可用:${headings.join("、")}`, blocker: "help-section-not-found" });
1261
+ if (missing.length > 0)
1262
+ console.error(`help: topic ${topic} 缺少 section:${missing.join("、")}(已返回其余)`);
1263
+ return emitTransportResult({ status: "ok", topic: document.name, ...(missing.length > 0 ? { missingSections: missing } : {}), sections: found }, transport);
1264
+ }
1265
+ if (sections || bareSection) {
1266
+ const document = topic === undefined ? undefined : readSkill(topic);
1267
+ if (!document)
1268
+ return emitInvalid(transport, { message: `未知 topic "${topic ?? ""}";先用 omc help topics 查看可用 topic` });
1269
+ const sliced = readSkillSection(topic, "__not-a-real-heading__");
1270
+ return emitTransportResult({ status: "ok", topic: document.name, sections: sliced?.headings ?? [] }, transport);
1271
+ }
1272
+ const singleSection = typeof section === "string" ? section : requestedSections.length === 1 ? requestedSections[0] : undefined;
1273
+ if (singleSection !== undefined) {
1274
+ const sliced = readSkillSection(topic, singleSection);
1275
+ if (sliced === undefined) {
1276
+ const message = `未知 topic "${topic}";可用 topic:${topics.map((entry) => entry.name).join("、")}(命令清单见 omc help commands)${commandPointer}`;
1277
+ console.error(`help: ${message}`);
1278
+ return emitInvalid(transport, { message });
1279
+ }
1280
+ if (!sliced.section)
1281
+ return emitInvalid(transport, { message: `topic ${topic} 没有 section "${singleSection}";可用:${sliced.headings.join("、")}`, blocker: "help-section-not-found" });
1282
+ return emitTransportResult({ status: "ok", topic: sliced.document.name, section: singleSection, content: sliced.section }, transport);
1283
+ }
1284
+ const document = readSkill(topic);
1285
+ if (document === undefined) {
1286
+ const message = `未知 topic "${topic}";可用 topic:${topics.map((entry) => entry.name).join("、")}(命令清单见 omc help commands)${commandPointer}`;
1287
+ console.error(`help: ${message}`);
1288
+ return emitInvalid(transport, { message });
1289
+ }
1290
+ console.error(`omc help ${document.name}`);
1291
+ return emitTransportResult({ status: "ok", topic: document }, transport);
1292
+ }
1293
+ /**
1294
+ * The text every agent should see when it lands on `omc --help`, `omc` with no
1295
+ * args, or `omc call --help`: how to reach the methodology router, the two
1296
+ * write gates, and the machine-readable tool discovery. Kept in one place so
1297
+ * those three surfaces can never drift apart.
1298
+ */
1299
+ function onboardingHelp() {
1300
+ return [
1301
+ "首次使用、或不确定从哪开始:omc help omc-start-here(随包权威 topic 路线);被拒或状态异常:omc help omc-refusals。",
1302
+ "目标→工具一步定位:omc next \"<你要做什么>\"(离线、只读,给能力/说明/首批命令;不确定会明说并回退到 omc-requirement-analysis)。",
1303
+ "工具发现:omc commands --json(含 capability id/lane/recipes;加 --compact 只回工具行、无目录散文)· omc capabilities(能力 id/lane/aliases/tools/依赖顺序)· omc capability <id> --executable(单能力完整执行包)。",
1304
+ "单工具/契约:omc describe <tool>(schema+recipe+能力路由)· omc contract <lane>(wire 契约+能力路由);环境就绪:omc call discover_environment --env <env> --args '{}'。",
1305
+ "写入两道门:对话层草稿确认(omc help omc-config-draft-review)+ 环境 writable 开关;生产环境每笔写需在 args 里带 confirmProduction:true。",
1306
+ ].join("\n");
1307
+ }
1308
+ async function main() {
1309
+ let commandExitCode = 0;
1310
+ beginUsage(argv);
1311
+ const command = new Command();
1312
+ try {
1313
+ configureOutput(argv);
1314
+ }
1315
+ catch (error) {
1316
+ // A malformed global output flag is invalid input, not an internal error.
1317
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: error instanceof Error ? error.message : String(error), retryable: false }));
1318
+ return CLI_EXIT_CODES.invalidInput;
1319
+ }
1320
+ command.exitOverride();
1321
+ command.configureOutput({ writeOut: (text) => process.stderr.write(text), writeErr: (text) => process.stderr.write(text) });
1322
+ command.name("omc").showHelpAfterError().showSuggestionAfterError(true);
1323
+ command.addHelpText("after", `\n${onboardingHelp()}\n`);
1324
+ // No command at all: guide the agent (help text + onboarding), exit 0. This
1325
+ // is a benign "show me the surface" invocation, not a failure.
1326
+ if (argv[2] === undefined) {
1327
+ command.outputHelp();
1328
+ return CLI_EXIT_CODES.ok;
1329
+ }
1330
+ command.command("help [topic]").option("--section [heading]", "按标题取该节内容;可重复或用逗号一次取多节:--section a --section b / --section a,b(不带值则列出该 topic 的节标题)", (value, previous) => [...previous, value], []).option("--sections").option("--max-output-bytes <bytes>").action(async (topic, options) => { commandExitCode = await withTransport(options, request, (transport) => cmdHelp(topic, command.commands.map((entry) => entry.name()), transport, options.section, options.sections === true)); });
1331
+ command.command("setup").option("--list").option("--edit <environment>").option("--remove <environment>").action(async (options) => {
1332
+ commandExitCode = await cmdSetup(new ConfigStore(defaultPaths()), [...(options.list ? ["--list"] : []), ...(options.edit ? ["--edit", options.edit] : []), ...(options.remove ? ["--remove", options.remove] : [])]);
1333
+ });
1334
+ command.command("doctor").option("--environment <environment>").option("--env <environment>").action(async (options) => { commandExitCode = await cmdDoctor(new ConfigStore(defaultPaths()), options.environment ?? options.env); });
1335
+ command.command("call <tool>").option("--env <environment>").option("--environment <environment>").option("--args <json>").option("--args-file <path>").option("--max-output-bytes <bytes>").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
1336
+ const rest = [tool, ...(options.env ? ["--env", options.env] : []), ...(options.environment ? ["--environment", options.environment] : []), ...(options.args !== undefined ? ["--args", options.args] : []), ...(options.argsFile !== undefined ? ["--args-file", options.argsFile] : []), ...(options.maxOutputBytes !== undefined ? ["--max-output-bytes", options.maxOutputBytes] : []), ...(options.requestId ? ["--request-id", options.requestId] : []), ...(options.timeoutMs ? ["--timeout-ms", options.timeoutMs] : []), ...(options.wait !== undefined ? ["--wait", options.wait] : []), ...(options.dryRun ? ["--dry-run"] : [])];
1337
+ commandExitCode = await cmdCall(new ConfigStore(defaultPaths()), rest);
1338
+ });
1339
+ command.command("artifact").command("get <id>").option("--jq <path>").option("--path-only").option("--summary").option("--keys [a,b]").option("--max-output-bytes <bytes>").action(async (id, options) => {
1340
+ commandExitCode = await withTransport(options, request, (transport) => cmdArtifactGet(id, transport, { ...(options.jq ? { jq: options.jq } : {}), pathOnly: options.pathOnly === true, summary: options.summary === true || options.keys === true, ...(typeof options.keys === "string" ? { keys: options.keys } : {}) }));
1341
+ });
1342
+ command.command("bench").option("--live-sample").option("--env <environment>").action(async (options) => { commandExitCode = await cmdBench([...(options.liveSample ? ["--live-sample"] : []), ...(options.env ? ["--env", options.env] : [])]); });
1343
+ command.command("commands").option("--json").option("--compact").option("--filter <keyword>").option("--lane <id>").option("--max-output-bytes <bytes>").action(async (options) => { commandExitCode = await withTransport(options, request, (transport) => cmdDiscovery("commands", transport, undefined, { json: options.json === true, compact: options.compact === true, ...(options.filter ? { filter: options.filter } : {}), ...(options.lane ? { lane: options.lane } : {}) })); });
1344
+ command.command("describe <tool>").option("--full").option("--schema-only").option("--param <name>").option("--example [id]").option("--max-output-bytes <bytes>").action(async (tool, options) => {
1345
+ commandExitCode = await withTransport(options, request, (transport) => cmdDiscovery("describe", transport, tool, { full: options.full === true, schemaOnly: options.schemaOnly === true, ...(options.param ? { param: options.param } : {}), ...(options.example ? { example: options.example } : {}) }));
1346
+ });
1347
+ command.command("contract <lane>").option("--full").option("--tool <name>").option("--max-output-bytes <bytes>").action(async (lane, options) => { commandExitCode = await withTransport(options, request, (transport) => cmdDiscovery("contract", transport, lane, { full: options.full === true, ...(options.tool ? { tool: options.tool } : {}) })); });
1348
+ command.command("capabilities").option("--max-output-bytes <bytes>").action(async (options) => { commandExitCode = await withTransport(options, request, (transport) => cmdCapabilities(transport)); });
1349
+ // `--env` is accepted but ignored: `next` is a zero-environment-contact route
1350
+ // solver, and a caller who reflexively passes `--env` should not be refused
1351
+ // (round 15 dogfood: `next "<goal>" --env local` → unknown option, one round).
1352
+ command.command("next <goal>").option("--env <environment>").option("--max-output-bytes <bytes>").action(async (goal, options) => {
1353
+ commandExitCode = await withTransport(options, request, (transport) => cmdNext(goal, transport));
1354
+ });
1355
+ command.command("usage").option("--max-output-bytes <bytes>").action(async (options) => {
1356
+ commandExitCode = await withTransport(options, request, (transport) => cmdUsage(transport));
1357
+ });
1358
+ command.command("capability <id>").option("--executable").option("--max-output-bytes <bytes>").action(async (id, options) => {
1359
+ commandExitCode = await withTransport(options, request, (transport) => cmdCapability(id, transport, options.executable === true));
1360
+ });
1361
+ const intentCommand = command.command("intent");
1362
+ intentCommand.command("schema").option("--example <id>").action((options) => { commandExitCode = cmdIntentSchema({ request, maxOutputBytes: CLI_DEFAULT_OUTPUT_BYTES }, typeof options.example === "string" ? options.example : undefined); });
1363
+ intentCommand.command("validate <file>").option("--show-intent").action((file, options) => { commandExitCode = cmdIntentValidate(file, { request, maxOutputBytes: CLI_DEFAULT_OUTPUT_BYTES }, options.showIntent === true); });
1364
+ intentCommand.command("plan <file>").option("--env <environment>").option("--plan-out <file>").option("--max-output-bytes <bytes>").action(async (file, options) => {
1365
+ commandExitCode = await withTransport(options, request, (transport) => cmdIntentPlan(file, options.env, options.planOut, transport));
1366
+ });
1367
+ 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) => {
1368
+ commandExitCode = await withTransport(options, request, (transport) => cmdIntentApply(file, { env: options.env, dryRun: options.dryRun === true, verify: options.verify !== false, confirmProduction: options.confirmProduction === true }, transport));
1369
+ });
1370
+ intentCommand.command("readback <file>").option("--env <environment>").option("--max-output-bytes <bytes>").action(async (file, options) => {
1371
+ commandExitCode = await withTransport(options, request, (transport) => cmdIntentReadback(file, options.env, transport));
1372
+ });
1373
+ await command.parseAsync(stripOutputFlags(argv));
1374
+ return commandExitCode;
1375
+ }
1376
+ const request = requestId();
1377
+ main().then((code) => {
1378
+ if (argv[2] === "call" || argv[2] === "artifact" || argv[2] === "commands" || argv[2] === "describe" || argv[2] === "contract" || argv[2] === "capabilities" || argv[2] === "capability" || argv[2] === "next" || argv[2] === "usage" || argv[2] === "intent" || argv[2] === "help") {
1379
+ completeUsage(code);
1380
+ exit(code);
1381
+ return;
1382
+ }
1383
+ writeEnvelope(envelope(code === 0 ? "ok" : "error", {}, request, code === 0 ? null : { code: "internal", message: "command failed", retryable: false }));
1384
+ completeUsage(code);
1385
+ exit(code);
1386
+ }, (error) => {
1387
+ // commander prints `--help` / `-h` usage to stderr and signals success with
1388
+ // this code; it is not an error and must not grow a spurious error envelope.
1389
+ if (error instanceof CommanderError && error.code === "commander.helpDisplayed") {
1390
+ completeUsage(0);
1391
+ exit(0);
1392
+ return;
1393
+ }
1394
+ const commander = error instanceof CommanderError;
1395
+ // A bare tool name (`omc discover_environment …`) is the most common cold-start
1396
+ // mistake: agents drop the `call` verb. Point them at the right invocation
1397
+ // instead of a bare "unknown command".
1398
+ let message = error instanceof Error ? error.message : String(error);
1399
+ if (commander && error.code === "commander.unknownCommand") {
1400
+ const quoted = /unknown command '([^']+)'/.exec(message)?.[1];
1401
+ if (quoted) {
1402
+ let isTool = false;
1403
+ try {
1404
+ isTool = buildToolRegistry().some((tool) => tool.name === quoted);
1405
+ }
1406
+ catch {
1407
+ isTool = false;
1408
+ }
1409
+ if (isTool)
1410
+ message = `未知顶层命令 "${quoted}"——工具要用 omc call ${quoted} --env <env> --args '<json>'(顶层命令见 omc help commands);环境名以 doctor 的「环境=」或 .omc-env 为准`;
1411
+ }
1412
+ }
1413
+ const parsed = errorEnvelope(new Error(message), commander ? "invalid-input" : "internal", commander ? CLI_EXIT_CODES.invalidInput : CLI_EXIT_CODES.internal, request);
1414
+ writeEnvelope(parsed.envelope);
1415
+ completeUsage(parsed.exitCode);
1416
+ exit(parsed.exitCode);
1417
+ });
1418
+ //# sourceMappingURL=cli.js.map