@stalfh233/omc-cli 0.6.7 → 0.6.10

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 (67) hide show
  1. package/README.md +2 -1
  2. package/dist/approval-flow.js +5 -3
  3. package/dist/bizservice-config.js +26 -10
  4. package/dist/call.js +47 -0
  5. package/dist/capability-overview.js +47 -24
  6. package/dist/cli-contract.js +73 -26
  7. package/dist/cli-output.js +41 -2
  8. package/dist/cli-stdout.js +74 -0
  9. package/dist/cli.js +135 -52
  10. package/dist/command-transport.js +8 -1
  11. package/dist/config-store.js +17 -2
  12. package/dist/discovery.js +62 -3
  13. package/dist/field-families.js +35 -6
  14. package/dist/goal-routes.js +118 -11
  15. package/dist/human-summary.js +11 -0
  16. package/dist/lanes/app-contract.js +12 -5
  17. package/dist/lanes/app.js +221 -134
  18. package/dist/lanes/apply-changes.js +2 -1
  19. package/dist/lanes/b2-registration-contract.js +3 -3
  20. package/dist/lanes/b2-registration.js +890 -693
  21. package/dist/lanes/businessrule.js +75 -51
  22. package/dist/lanes/custom-page.js +166 -115
  23. package/dist/lanes/datarule-contract.js +2 -2
  24. package/dist/lanes/datarule.js +238 -154
  25. package/dist/lanes/dictionary.js +253 -110
  26. package/dist/lanes/environment.js +57 -9
  27. package/dist/lanes/field.js +160 -107
  28. package/dist/lanes/folder-contract.js +7 -3
  29. package/dist/lanes/folder.js +249 -118
  30. package/dist/lanes/form-contract.js +1 -1
  31. package/dist/lanes/form.js +188 -64
  32. package/dist/lanes/list-view.js +405 -369
  33. package/dist/lanes/meta.js +31 -6
  34. package/dist/lanes/model-contract.js +1 -1
  35. package/dist/lanes/model.js +610 -512
  36. package/dist/lanes/online-js-contract.js +1 -1
  37. package/dist/lanes/online-js.js +160 -146
  38. package/dist/lanes/organization.js +905 -640
  39. package/dist/lanes/programming.js +274 -136
  40. package/dist/lanes/report-contract.js +2 -2
  41. package/dist/lanes/report.js +354 -198
  42. package/dist/lanes/rule-lifecycle.js +294 -140
  43. package/dist/lanes/runtime-data-contract.js +4 -4
  44. package/dist/lanes/runtime-data.js +869 -745
  45. package/dist/lanes/workflow-contract.js +3 -3
  46. package/dist/lanes/workflow.js +732 -605
  47. package/dist/model-scaffold.js +26 -5
  48. package/dist/path-projection.js +196 -14
  49. package/dist/plan.js +25 -0
  50. package/dist/programming-workflow.js +55 -3
  51. package/dist/read-shapes.js +146 -0
  52. package/dist/read.js +40 -9
  53. package/dist/recipes/businessrule.js +74 -24
  54. package/dist/recipes/dictionary.js +7 -4
  55. package/dist/recipes/form.js +46 -0
  56. package/dist/recipes/runtime-data.js +3 -2
  57. package/dist/refusal-guidance.js +29 -5
  58. package/dist/rule-support.js +14 -1
  59. package/dist/rule-when.js +2 -2
  60. package/dist/tool-types.js +9 -3
  61. package/dist/version-family-gaps.js +101 -0
  62. package/dist/write.js +2 -1
  63. package/package.json +6 -4
  64. package/skills/omc-glossary.md +13 -0
  65. package/skills/omc-refusals.md +13 -5
  66. package/skills/omc-tool-selection.md +2 -1
  67. package/skills/omc-write-hazards.md +5 -2
package/README.md CHANGED
@@ -52,7 +52,8 @@ omc setup && omc doctor && omc commands --json
52
52
  `route`、`readiness`、`distributedMirror`)。
53
53
 
54
54
  ```bash
55
- omc capabilities # 能力 nickname/id/lane/aliases/tools/commands 一张表
55
+ omc capabilities # 能力 id/lane/aliases/tools/依赖顺序 一张表
56
+ omc capabilities --compact # 只要能力索引(~2.5KB,工具名按需 omc capability <id>)
56
57
  omc capability <id-or-alias> --executable # 选中能力的完整执行包(离线,不接触环境)
57
58
  omc contract <lane-from-the-capability> # wire 契约 + 该能力路由
58
59
  omc describe <tool-from-the-bundle> # schema + recipe + 能力路由
@@ -88,8 +88,10 @@ export async function cmdApprovalFlow(args) {
88
88
  catch (error) {
89
89
  return invalid(error instanceof Error ? error.message : String(error));
90
90
  }
91
+ // F1(r27):边界前置——成功回执就标出跨账号审批在单会话 CLI 内不可用。
92
+ const crossAccountApproval = "unsupported: single-session CLI";
91
93
  if (!apply) {
92
- writeEnvelope(envelope("ok", { schemaCode: schema, ...compiled, dryRun: true, note: "dry-run 仅编译预览;--apply 执行 workflow.create(无流程时)→update(模板+字段权限)→publish" }, request));
94
+ writeEnvelope(envelope("ok", { schemaCode: schema, ...compiled, dryRun: true, crossAccountApproval, note: "dry-run 仅编译预览;--apply 执行 workflow.create(无流程时)→update(模板+字段权限)→publish。边界:非发起人身份的审批无法在单会话 CLI 内推进(单 token);若要验跨账号审批,用第二账号另建环境,或把参与人设为 {Originator}" }, request));
93
95
  return 0;
94
96
  }
95
97
  // 1) 确保流程存在(一个模型只能有一个:已存在则直接 update)
@@ -138,9 +140,9 @@ export async function cmdApprovalFlow(args) {
138
140
  // 4) publish
139
141
  const publishOutcome = await runToolCall("workflow.publish", { args: { schemaCode: schema, code: compiled.workflowCode, ...(environment ? { environment } : {}) }, requestId: request, environment, print: () => undefined });
140
142
  const classified = classifyResult(publishOutcome.result);
141
- writeEnvelope(envelope(classified.status, { schemaCode: schema, workflowCode: compiled.workflowCode, steps: steps.length, ...preexistingSection, publish: publishOutcome.result }, request, classified.error));
143
+ writeEnvelope(envelope(classified.status, { schemaCode: schema, workflowCode: compiled.workflowCode, steps: steps.length, crossAccountApproval, ...preexistingSection, publish: publishOutcome.result }, request, classified.error));
142
144
  if (publishOutcome.exitCode === 0)
143
- process.stderr.write("[omc] 前台生效验证清单:1) 造一条数据提交走流程;2) 每级审批人在门户有待办(角色用 FindUserByRole 需审批人挂在发起人同部门,否则 PENDING 不来);3) 审批节点字段只读(不可改单);4) 驳回退发起人核对;5) 设计器打开流程确认节点链显示正常。\n");
145
+ process.stderr.write("[omc] 前台生效验证清单:1) 造一条数据提交走流程;2) 每级审批人在门户有待办(角色用 FindUserByRole 需审批人挂在发起人同部门,否则 PENDING 不来);3) 审批节点字段只读(不可改单);4) 驳回退发起人核对;5) 设计器打开流程确认节点链显示正常。边界:非发起人身份的审批无法在单会话 CLI 内推进(单 token);跨账号请另建环境或把参与人设为 {Originator}。\n");
144
146
  return publishOutcome.exitCode;
145
147
  }
146
148
  //# sourceMappingURL=approval-flow.js.map
@@ -531,22 +531,38 @@ function resolveExternParameterType(parameter, label, index, collect) {
531
531
  return hasChildren ? EXTERN_PARAMETER_NAMES.OBJECT : EXTERN_PARAMETER_NAMES.PROPERTY;
532
532
  }
533
533
  /**
534
- * Read-side parameter-face normalization: the platform stores each parameter's
535
- * `configJson.position` as the WIRE INTEGER index (platform built-ins:
536
- * `{"position":3}`). Decode it back to the canonical NAME for a readable
537
- * readback (mirrors `normalizeMethodConfigReadback`). Non-RESTFUL faces pass
538
- * through untouched.
534
+ * Read-side parameter-face normalization.
535
+ *
536
+ * - `configJson.position` (RESTFUL only): wire INTEGER → canonical NAME
537
+ * (mirrors `normalizeMethodConfigReadback`). Non-RESTFUL faces leave position alone.
538
+ * - `bizPropertyType` (every adapter): wire INTEGER → canonical NAME, with the
539
+ * original index kept alongside as `bizPropertyTypeIndex` so callers can
540
+ * still match against platform int tables. A name already present passes
541
+ * through unchanged (idempotent).
542
+ *
543
+ * r28f F4: register readback used to echo bare wire ints while method.get
544
+ * decoded position-only — one decoder, one face.
539
545
  */
540
546
  export function normalizeParameterFaceReadback(adapterCode, parameters) {
541
- if (adapterCode !== "RESTFUL" || !Array.isArray(parameters))
547
+ if (!Array.isArray(parameters))
542
548
  return parameters;
543
549
  return parameters.map((parameter) => {
544
550
  if (!isRecord(parameter))
545
551
  return parameter;
546
- const config = isRecord(parameter.configJson) ? parameter.configJson : typeof parameter.configJson === "string" ? safeParse(parameter.configJson) : undefined;
547
- if (!isRecord(config) || !(typeof config.position === "number" && Number.isInteger(config.position) && config.position in RESTFUL_PARAMETER_POSITIONS))
548
- return parameter;
549
- return { ...parameter, configJson: { ...config, position: RESTFUL_PARAMETER_POSITIONS[config.position] } };
552
+ let next = parameter;
553
+ // bizPropertyType is adapter-agnostic (BizPropertyType.java).
554
+ const rawType = parameter.bizPropertyType;
555
+ if (typeof rawType === "number" && Number.isInteger(rawType) && rawType in BIZ_PROPERTY_TYPES) {
556
+ next = { ...next, bizPropertyType: BIZ_PROPERTY_TYPES[rawType], bizPropertyTypeIndex: rawType };
557
+ }
558
+ // position decode is RESTFUL-only (BizServiceLocationType).
559
+ if (adapterCode === "RESTFUL") {
560
+ const config = isRecord(next.configJson) ? next.configJson : typeof next.configJson === "string" ? safeParse(next.configJson) : undefined;
561
+ if (isRecord(config) && typeof config.position === "number" && Number.isInteger(config.position) && config.position in RESTFUL_PARAMETER_POSITIONS) {
562
+ next = { ...next, configJson: { ...config, position: RESTFUL_PARAMETER_POSITIONS[config.position] } };
563
+ }
564
+ }
565
+ return next;
550
566
  });
551
567
  }
552
568
  /**
package/dist/call.js CHANGED
@@ -55,6 +55,50 @@ export function readArgsFile(path, root = resolve(process.env.OMC_CONFIG_HOME ??
55
55
  const fd = openSync(safePath, "r");
56
56
  return parseJsonArgs(readBounded(fd, "--args-file"), "--args-file");
57
57
  }
58
+ /**
59
+ * F7(r27):按工具 description/scope 做意图词检索。前缀/编辑距离在跨族
60
+ * 猜错时会空手(`app.tree` → 只给 `app.*`),而真实工具 `folder.tree` 的描述
61
+ * 里写着「应用树」。只补 1–2 个、且排在原候选之后,不改 didYouMean 类型。
62
+ */
63
+ export function toolsByTextIntent(query, registry, limit = 2) {
64
+ const rawTokens = query
65
+ .toLowerCase()
66
+ .split(/[\s._\-/:]+/u)
67
+ .map((token) => token.trim())
68
+ .filter((token) => token.length >= 2);
69
+ if (rawTokens.length === 0)
70
+ return [];
71
+ const intentExpand = {
72
+ tree: ["tree", "树", "目录", "folder", "应用树"],
73
+ 树: ["tree", "树", "目录", "folder", "应用树"],
74
+ 目录: ["tree", "树", "目录", "folder", "应用树"],
75
+ folder: ["tree", "树", "目录", "folder", "应用树"],
76
+ };
77
+ const tokens = new Set();
78
+ for (const token of rawTokens) {
79
+ tokens.add(token);
80
+ for (const expanded of intentExpand[token] ?? [])
81
+ tokens.add(expanded);
82
+ }
83
+ const scored = [];
84
+ for (const tool of registry) {
85
+ const haystack = `${tool.name}\n${tool.description ?? ""}\n${tool.scope ?? ""}`.toLowerCase();
86
+ let score = 0;
87
+ for (const token of tokens)
88
+ if (haystack.includes(token))
89
+ score += token.length >= 3 ? 2 : 1;
90
+ // Require at least one query token (not only expanded synonyms) to avoid
91
+ // ranking every folder.* tool for an unrelated ask that happens to expand.
92
+ const hitRaw = rawTokens.some((token) => haystack.includes(token));
93
+ if (!hitRaw || score <= 0)
94
+ continue;
95
+ scored.push({ name: tool.name, score });
96
+ }
97
+ return scored
98
+ .sort((left, right) => right.score - left.score || left.name.localeCompare(right.name))
99
+ .slice(0, limit)
100
+ .map((entry) => entry.name);
101
+ }
58
102
  export async function runToolCall(toolName, options = {}) {
59
103
  const print = options.print ?? ((line) => console.log(line));
60
104
  const context = options.context ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
@@ -71,6 +115,8 @@ export async function runToolCall(toolName, options = {}) {
71
115
  // closestMatches 按名字相似度补 3 个跨族候选,并把下一条命令直接写出来。
72
116
  const nearest = closestMatches(toolName, names, 3).map((entry) => entry.name).filter((name) => !candidates.includes(name));
73
117
  const didYouMean = [...candidates.slice(0, 3), ...nearest].slice(0, 5);
118
+ // F7:description/scope 意图词补位(排在原候选之后,另字段不改 didYouMean 类型)。
119
+ const byText = toolsByTextIntent(toolName, registry, 2).filter((name) => !didYouMean.includes(name) && !candidates.includes(name));
74
120
  // A requested operation the PLATFORM itself does not expose (e.g.
75
121
  // dictionary.delete) must not read as an OMC gap: name the real recovery
76
122
  // path and the documented absence instead of only the nearest tools.
@@ -80,6 +126,7 @@ export async function runToolCall(toolName, options = {}) {
80
126
  tool: toolName,
81
127
  ...(candidates.length > 0 ? { candidates } : {}),
82
128
  ...(didYouMean.length > 0 ? { didYouMean, nextCommand: `omc describe ${didYouMean[0]}` } : {}),
129
+ ...(byText.length > 0 ? { didYouMeanByText: byText } : {}),
83
130
  ...(absence ? { platformAbsence: absence.replace(/^;/, "") } : {}),
84
131
  // 跨族/表意不清时,「猜名字」本身就不该是路径——用目标路由一步到位。
85
132
  goalCommand: `omc next "${toolName.replace(/[._]/g, " ")}"`,
@@ -28,23 +28,23 @@ const SURFACES = [
28
28
  },
29
29
  {
30
30
  id: "app",
31
- title: "应用",
31
+ title: "应用包(app)",
32
32
  lane: LANES.app,
33
- description: "应用包(app)生命周期:列出、创建、更新(分组移动)、admin 删除。",
33
+ description: "应用包(app.*)生命周期:列出、创建、更新(分组移动)、admin 删除。不是应用内的目录节点——目录用 folder。",
34
34
  operations: ["create", "update", "delete"],
35
35
  requires: [],
36
36
  intent: { level: "none", note: "未覆盖;用 omc call app.*。" },
37
- notes: "环境已带默认 applicationCode,建模不必先建应用。",
37
+ notes: "环境已带默认 applicationCode,建模不必先建应用。新建应用平台会自动塞一个默认分组(folder.isDefault);有模型/报表等非目录节点时 app.delete 拒 app-not-empty。",
38
38
  },
39
39
  {
40
40
  id: "folder",
41
- title: "应用目录(分组)",
41
+ title: "应用内目录节点(folder)",
42
42
  lane: LANES.folder,
43
- description: "应用树目录:读(递归展平带 path/depth)、建、改名/挂载、非递归删除。",
43
+ description: "应用内目录/分组节点(folder.*,不是应用包 app):读树(递归展平 path/depth)、建、改名/挂载、非递归删除。",
44
44
  operations: ["tree", "create", "update", "delete"],
45
45
  requires: ["app"],
46
46
  intent: { level: "none", note: "未覆盖;用 omc call folder.*。移动模型用 model.move。" },
47
- notes: "省略 parentId 建顶层目录(平台默认挂应用根);平台不校验重名(工具默认同父同名拒绝);删除非递归(有子节点回 DIRECTORY_NOT_EMPTY);应用树是嵌套 children,folder.tree 已展平。",
47
+ notes: "省略 parentId 建顶层目录(平台默认挂应用根);平台不校验重名(工具默认同父同名拒绝);删除非递归(有子节点回 DIRECTORY_NOT_EMPTY);应用树是嵌套 children,folder.tree 已展平。不存在的应用回 applicationExists:false(仍 ok+空树)。平台默认分组节点带 isDefault:true。",
48
48
  },
49
49
  {
50
50
  id: "dictionary",
@@ -123,11 +123,11 @@ const SURFACES = [
123
123
  id: "workflow",
124
124
  title: "流程模板",
125
125
  lane: LANES.workflow,
126
- description: "流程模板生命周期:get_draft/get_published、create、update、publish、bindRule、delete、workitem.list。",
126
+ description: "流程模板生命周期:get_draft/get_published、create、update、publish、bindRule、delete、workitem.list。**含组织/部门/角色/权限范围(org.* 工具)**:部门树、角色组/角色、用户搜索、成员与管理范围(userScope)——流程参与人/可见性的候选来源。",
127
127
  operations: ["create", "update", "publish", "bindRule", "delete"],
128
128
  requires: ["model"],
129
129
  intent: { level: "partial", note: "声明式支持 workflow.create/update/publish 与 bindRules(事件处理器规则绑定,merge/ensure-present);runtime workitem(提交/审批)仍走 escape hatch。" },
130
- notes: "greenfield 首版;publish/bindRule 不得在 ValidateMessage[] 拒绝时报告成功。",
130
+ notes: "greenfield 首版;publish/bindRule 不得在 ValidateMessage[] 拒绝时报告成功。组织读写(org.*)挂在本能力下,别名 org/organization/organisation。",
131
131
  },
132
132
  {
133
133
  id: "onlinejs",
@@ -190,7 +190,8 @@ const CAPABILITY_ALIASES = Object.freeze({
190
190
  datarule: ["data-rules", "G1-datarule"],
191
191
  businessrule: ["business-rules", "B1-businessrule"],
192
192
  "rule-lifecycle": ["rule-lifecycles", "S25-rule-lifecycle"],
193
- workflow: ["workflows", "W7-S29-greenfield"],
193
+ // r28h F1/F7: org.* lives under workflow; aliases let "我要管部门/组织" hit it.
194
+ workflow: ["workflows", "W7-S29-greenfield", "org", "organization", "organisation", "组织", "组织架构"],
194
195
  onlinejs: ["online-js"],
195
196
  programming: ["backend-online-coding", "programming-online-dev", "在线开发", "在线编码"],
196
197
  bizservice: ["bizservices", "B2-registration"],
@@ -238,7 +239,13 @@ export const CAPABILITY_CUES = Object.freeze({
238
239
  // rule is named by 业务规则/触发器/规则图, not by "automatic".
239
240
  businessrule: ["业务规则", "触发器", "规则图", "校验", "事件", "按钮", "定时任务", "定时触发", "定时器", "每天", "每日", "周期执行", "调度", "SCHEDULER", "TIMING_TASK"],
240
241
  "rule-lifecycle": ["启用规则", "停用规则", "删除规则", "规则启停", "触发规则", "执行规则", "跑规则", "按钮规则", "规则验收"],
241
- workflow: ["流程", "工作流", "审批", "节点", "待办", "流转", "参与人", "审批人", "角色", "部门负责人", "审批角色"],
242
+ // r28h F1/F7: narrow org cues so "管理组织:部门/角色/角色组/用户/权限范围"
243
+ // hits workflow (where org.* lives) instead of field→workflow. Keep them
244
+ // compound/longer than field's bare "部门"/"人员" so they outscore field.
245
+ // Also include bare "部门"/"组织" here so an org ask that only says 部门 still
246
+ // ties/beats field (alias "组织" already scores via CAPABILITY_ALIASES).
247
+ workflow: ["流程", "工作流", "审批", "节点", "待办", "流转", "参与人", "审批人", "角色", "部门负责人", "审批角色",
248
+ "组织架构", "组织管理", "管理组织", "角色组", "权限范围", "管理范围", "userScope", "部门树", "员工账号", "组织"],
242
249
  onlinejs: ["在线js", "脚本", "联动", "onload", "onvalidate"],
243
250
  programming: ["在线开发", "在线编码", "后端源码", "java源码", "编码模式", "handler", "programming", "源码编译"],
244
251
  bizservice: ["集成", "接口", "服务", "回调", "对接", "外部系统", "datasql", "restful"],
@@ -522,33 +529,49 @@ export function executableCapabilityBundle(registry, id) {
522
529
  * node keeps only `id`. The capability id/lane/tools/order/coverage truth is
523
530
  * unchanged; only the repetition is gone.
524
531
  */
525
- export function compactCapabilities(registry) {
532
+ export function compactCapabilities(registry, options = {}) {
526
533
  const index = capabilityRouteIndex(registry);
534
+ // `--compact`(HANDOFF §三.3-7):默认面 ~8 KB,其中 `tools` 一项就占 ~3 KB。
535
+ // 冷启动只需要「有哪些能力、在哪个 lane、能不能声明式做」,工具名单用
536
+ // `omc capability <id>` 按需取。压缩档因此把 tools 换成 toolCount,并**明说**
537
+ // 省略了工具名,不让读者误以为该能力没有工具。
538
+ const compact = options.compact === true;
527
539
  return {
528
540
  status: "ok",
529
541
  discovery: DISCOVERY_AUTHORITY,
530
542
  // Size hint (dogfood R1): `capabilities` is ~6 KB, not a "safe small" face.
531
543
  // Keep it a one-line pointer so it does not perturb the discovery-budget
532
544
  // baseline more than a hair.
533
- outputHint: "~6KB 非安全小档;next 已给路线时可省,单能力用 omc capability <id>",
545
+ outputHint: compact
546
+ ? "紧凑索引(无工具名单);工具名用 omc capability <id>,完整索引去掉 --compact"
547
+ : "~6KB 非安全小档;next 已给路线时可省,单能力用 omc capability <id>;只要能力索引可加 --compact",
534
548
  commands: {
535
549
  detail: "omc capability <id>",
536
550
  executable: "omc capability <id> --executable",
537
551
  contract: "omc contract <lane>",
538
552
  readiness: DISCOVERY_AUTHORITY.readiness,
539
553
  },
540
- capabilities: index.routes.map((route) => ({
541
- id: route.id,
542
- title: route.title,
543
- lane: route.lane,
544
- aliases: route.aliases,
545
- tools: route.tools,
546
- relatedTopics: route.relatedTopics,
547
- requires: index.nodeById.get(route.id)?.requires ?? [],
548
- precedes: index.nodeById.get(route.id)?.precedes ?? [],
549
- intent: index.nodeById.get(route.id)?.intent.level ?? "none",
550
- coverage: index.nodeById.get(route.id)?.coverage ?? "unregistered",
551
- })),
554
+ ...(compact ? { toolsInlined: false, toolsHint: "每个能力的具体工具名见 omc capability <id>;完整索引用 omc capabilities(不带 --compact)" } : {}),
555
+ capabilities: index.routes.map((route) => compact
556
+ ? {
557
+ id: route.id,
558
+ lane: route.lane,
559
+ intent: index.nodeById.get(route.id)?.intent.level ?? "none",
560
+ coverage: index.nodeById.get(route.id)?.coverage ?? "unregistered",
561
+ toolCount: route.tools.length,
562
+ }
563
+ : {
564
+ id: route.id,
565
+ title: route.title,
566
+ lane: route.lane,
567
+ aliases: route.aliases,
568
+ tools: route.tools,
569
+ relatedTopics: route.relatedTopics,
570
+ requires: index.nodeById.get(route.id)?.requires ?? [],
571
+ precedes: index.nodeById.get(route.id)?.precedes ?? [],
572
+ intent: index.nodeById.get(route.id)?.intent.level ?? "none",
573
+ coverage: index.nodeById.get(route.id)?.coverage ?? "unregistered",
574
+ }),
552
575
  };
553
576
  }
554
577
  //# sourceMappingURL=capability-overview.js.map
@@ -234,20 +234,43 @@ export function nextCommandFor(result) {
234
234
  }
235
235
  return undefined;
236
236
  }
237
+ /**
238
+ * Top-level envelope result statuses. Anything outside this set falls through
239
+ * to the unclassified-status / auth-config catch-all (exit 15/11). Historical
240
+ * accidents (0.6.6/0.6.7): `meta.get_status` → `"failed"`, `rollback apply` →
241
+ * `"stopped"` both used to land in `internal` before they were added here.
242
+ */
243
+ export const RESULT_STATUSES = ["ok", "dry-run", "partial", "unknown-tool", "uncertain", "refused", "failed", "stopped", "blocked", "error"];
244
+ export const RESULT_STATUS_ROUTES = Object.freeze({
245
+ ok: { kind: "success", envelopeStatus: "ok", exitCode: CLI_EXIT_CODES.ok },
246
+ "dry-run": { kind: "success", envelopeStatus: "dry-run", exitCode: CLI_EXIT_CODES.ok },
247
+ partial: { kind: "partial", envelopeStatus: "partial", code: "partial-batch", exitCode: 1 },
248
+ "unknown-tool": { kind: "unknown-tool", envelopeStatus: "error", code: "invalid-input", exitCode: CLI_EXIT_CODES.invalidInput },
249
+ uncertain: { kind: "uncertain", envelopeStatus: "uncertain", code: "uncertain", exitCode: CLI_EXIT_CODES.uncertain },
250
+ refused: { kind: "refused", envelopeStatus: "refused" },
251
+ failed: { kind: "step-chain", envelopeStatus: "error", code: "step-chain-failed", exitCode: 1 },
252
+ stopped: { kind: "step-chain", envelopeStatus: "error", code: "step-chain-failed", exitCode: 1 },
253
+ blocked: { kind: "step-chain", envelopeStatus: "error", code: "step-chain-failed", exitCode: 1 },
254
+ // `error` keeps today's self-produced / blocker-bypass path: it is listed so
255
+ // the producer scan accepts it, but classifyResult does not give it a stable
256
+ // ordinary-path mapping of its own.
257
+ error: { kind: "passthrough" },
258
+ });
237
259
  export function classifyResult(result) {
238
260
  const status = String(result.status ?? "ok");
239
261
  const withNext = (error) => {
240
262
  const nextCommand = nextCommandFor(result);
241
263
  return nextCommand === undefined ? error : { ...error, nextCommand };
242
264
  };
243
- if (status === "ok")
244
- return { status, error: null, exitCode: CLI_EXIT_CODES.ok };
245
- if (status === "dry-run")
246
- return { status, error: null, exitCode: CLI_EXIT_CODES.ok };
265
+ const route = RESULT_STATUSES.includes(status)
266
+ ? RESULT_STATUS_ROUTES[status]
267
+ : undefined;
268
+ if (route?.kind === "success")
269
+ return { status: route.envelopeStatus, error: null, exitCode: route.exitCode };
247
270
  // `--args-list` partial success (some entries landed, some refused). Without
248
271
  // this branch it fell through to "internal error", which reads as a CLI bug
249
272
  // rather than "2 of 3 fields were created — retry index 2".
250
- if (status === "partial") {
273
+ if (route?.kind === "partial") {
251
274
  const succeeded = Number(result.succeeded ?? 0);
252
275
  const requested = Number(result.requested ?? 0);
253
276
  const failed = Number(result.failed ?? 0);
@@ -262,15 +285,19 @@ export function classifyResult(result) {
262
285
  ? `${succeeded}/${requested} 成功,${failed} 失败;逐条原因见 failures[] / failedSteps[](--args-list 的 index 指向第几条)`
263
286
  : `部分步骤失败:逐条原因见 steps[] 的 status/detail(已在失败处停下;幂等,修好后重跑同一条命令)`;
264
287
  return {
265
- status,
266
- error: withNext({ code: "partial-batch", message, retryable: false }),
267
- exitCode: 1,
288
+ status: route.envelopeStatus,
289
+ error: withNext({ code: route.code, message, retryable: false }),
290
+ exitCode: route.exitCode,
268
291
  };
269
292
  }
270
- if (status === "unknown-tool") {
293
+ if (route?.kind === "unknown-tool") {
271
294
  const candidates = Array.isArray(result.candidates) ? result.candidates : [];
272
295
  const suffix = candidates.length > 0 ? `;did you mean: ${candidates.join("、")}` : ";用 `omc commands --json --compact --lane <lane>` 或 `omc next` 定位工具";
273
- return { status: "error", error: withNext({ code: "invalid-input", message: `unknown tool ${String(result.tool ?? "")}${suffix}`, retryable: false }), exitCode: CLI_EXIT_CODES.invalidInput };
296
+ return {
297
+ status: route.envelopeStatus,
298
+ error: withNext({ code: route.code, message: `unknown tool ${String(result.tool ?? "")}${suffix}`, retryable: false }),
299
+ exitCode: route.exitCode,
300
+ };
274
301
  }
275
302
  // r25 探针实证(2026-11-04):`blocker: "invalid-input"` 有 8 处产出点
276
303
  // (workflow/runtime-data/compound-writes),但这里只认 `tool-input-*`——于是
@@ -278,16 +305,30 @@ export function classifyResult(result) {
278
305
  // 读成"CLI 内部故障",而实际是**自己传错了参数**(探针原话:
279
306
  // 「rejected ✓, but error.code=internal (should be invalid-input), and no nextCommand」)。
280
307
  // 入参形状错误一律 exit 10,并给出下一步。
308
+ // Priority: must stay ahead of the remaining status routes (uncertain /
309
+ // refused / step-chain / unclassified) so `status:"refused"|"error"` with an
310
+ // input-shape blocker still exits 10. Do not move this above ok/dry-run/
311
+ // partial/unknown-tool — that would change today's semantics.
281
312
  const inputShapeBlocker = result.blocker === "tool-input-invalid"
282
313
  || result.blocker === "tool-input-unknown-keys"
283
314
  || result.blocker === "invalid-input";
284
315
  if (inputShapeBlocker) {
285
316
  // `invalid-input` 也可能是 CLI 层参数校验(compound-writes 的 --schema 必填),
286
317
  // 那时没有 tool 字段——别硬拼一条指向不存在的工具的 describe。
318
+ // 工具在时进一步收窄到**失败的那个参数**(`--param <顶层键>`):整份 describe
319
+ // 是 4–17 KB,而 issues[].path 已经把位置说清楚了(HANDOFF §三.1-1)。
287
320
  const toolName = typeof result.tool === "string" && result.tool ? result.tool : undefined;
321
+ const failedParam = Array.isArray(result.issues)
322
+ ? result.issues
323
+ .map((issue) => issue?.path)
324
+ .map((path) => (Array.isArray(path) ? path[0] : path))
325
+ .find((segment) => typeof segment === "string" && segment.length > 0)
326
+ : undefined;
288
327
  const nextCommand = typeof result.nextCommand === "string"
289
328
  ? result.nextCommand
290
- : toolName !== undefined ? `omc describe ${toolName}` : undefined;
329
+ : toolName !== undefined
330
+ ? `omc describe ${toolName}${failedParam !== undefined ? ` --param ${String(failedParam).split(".")[0]}` : ""}`
331
+ : undefined;
291
332
  return {
292
333
  status: "error",
293
334
  error: {
@@ -297,39 +338,45 @@ export function classifyResult(result) {
297
338
  exitCode: CLI_EXIT_CODES.invalidInput,
298
339
  };
299
340
  }
300
- if (status === "uncertain")
301
- return { status, error: withNext({ code: "uncertain", message: String(result.message ?? result.reason ?? "outcome is uncertain"), retryable: true }), exitCode: CLI_EXIT_CODES.uncertain };
302
- if (status === "refused") {
341
+ if (route?.kind === "uncertain") {
342
+ return {
343
+ status: route.envelopeStatus,
344
+ error: withNext({ code: route.code, message: String(result.message ?? result.reason ?? "outcome is uncertain"), retryable: true }),
345
+ exitCode: route.exitCode,
346
+ };
347
+ }
348
+ if (route?.kind === "refused") {
303
349
  if (result.blocker === "write-lock-held")
304
- return { status, error: withNext({ code: "policy-refused", message: String(result.message ?? "write lock held"), retryable: true }), exitCode: CLI_EXIT_CODES.policyRefused };
350
+ return { status: route.envelopeStatus, error: withNext({ code: "policy-refused", message: String(result.message ?? "write lock held"), retryable: true }), exitCode: CLI_EXIT_CODES.policyRefused };
305
351
  const auth = String(result.blocker ?? "").startsWith("auth") || String(result.blocker ?? "").includes("credential");
306
352
  // Every refusal gets a next step: a specific command when the tool knows
307
353
  // one, otherwise the refusal router (`omc-refusals` explains which gate
308
354
  // produced it — the documented path in omc-start-here).
309
355
  const error = { code: auth ? "auth-config" : "policy-refused", message: String(result.message ?? result.blocker ?? "operation refused"), retryable: false };
310
356
  const nextCommand = nextCommandFor(result) ?? (auth ? undefined : "omc help omc-refusals");
311
- return { status, error: nextCommand === undefined ? error : { ...error, nextCommand }, exitCode: auth ? CLI_EXIT_CODES.authConfig : CLI_EXIT_CODES.policyRefused };
357
+ return { status: route.envelopeStatus, error: nextCommand === undefined ? error : { ...error, nextCommand }, exitCode: auth ? CLI_EXIT_CODES.authConfig : CLI_EXIT_CODES.policyRefused };
312
358
  }
313
359
  // r25 沙盒(2026-11-04,B1 的同类):`meta.get_status` 返回 `status:"failed"`、
314
- // `rollback apply` 返回 `status:"stopped"`——两个都**不在**上面任何分支里,于是
315
- // 一路落到末尾的 internal(exit 15):调用方看到「CLI 内部故障」,其实是「配置/凭据
316
- // 读回失败」或「恢复链在 N 步停下」。这类状态是**步骤链的中途状态**,语义就是
317
- // partial:先跑了一些、然后停在某处。归到 exit 1 并保留 steps/结果原文。
318
- if (status === "failed" || status === "stopped" || status === "blocked") {
360
+ // `rollback apply` 返回 `status:"stopped"`——两个都曾掉进 internal(exit 15):
361
+ // 调用方看到「CLI 内部故障」,其实是「配置/凭据读回失败」或「恢复链在 N 步停下」。
362
+ // 这类状态是**步骤链的中途状态**,语义就是 partial:先跑了一些、然后停在某处。
363
+ // 归到 exit 1 并保留 steps/结果原文。
364
+ if (route?.kind === "step-chain") {
319
365
  return {
320
- status: "error",
366
+ status: route.envelopeStatus,
321
367
  error: withNext({
322
- code: "step-chain-failed",
368
+ code: route.code,
323
369
  message: String(result.message ?? `${status}:链在中途停止;逐条原因见 steps[] 的 status/error`),
324
370
  retryable: false,
325
371
  }),
326
- exitCode: 1,
372
+ exitCode: route.exitCode,
327
373
  };
328
374
  }
375
+ // `error` (passthrough) and any unknown status share the unclassified catch-all.
376
+ // Keep the actual `result.status` in the message (B10/B11): a brand-new value
377
+ // must never collapse to a bare "CLI 内部故障".
329
378
  const message = String(result.message ?? "internal error");
330
379
  const auth = /credential|configuration|environment|login|authentication|auth/i.test(message);
331
- // 兜底也不再谎报「internal」:把**实际收到**的 status/blocker 报出来,否则一个新的
332
- // 状态值(如上面两个)永远只会显示成「CLI 内部故障」,无法定位。
333
380
  return {
334
381
  status: "error",
335
382
  error: withNext({
@@ -2,6 +2,42 @@ import { writeFileSync } from "node:fs";
2
2
  import { asciiStdout, serializeEnvelope } from "./cli-contract.js";
3
3
  import { currentUsageCommand, priorRepeatedFailures, recordUsageOutput } from "./usage-log.js";
4
4
  let options = { quiet: false, verbose: false };
5
+ /**
6
+ * F2'(r27):写面 + 非 TTY 时,stderr 人话末尾补一行「勿 2>&1 合流」提示。
7
+ * 读面保持安静;stdout 字节不变。状态由 beginWriteFace / noteHumanStderr /
8
+ * maybeWarnMergedStreams 协作,避免每个调用点手写条件。
9
+ */
10
+ let writeFaceActive = false;
11
+ let humanStderrEmitted = false;
12
+ let pipeMergeHintEmitted = false;
13
+ /** Mark the current command as a write-face (call/script/composers that mutate). */
14
+ export function beginWriteFace() {
15
+ writeFaceActive = true;
16
+ humanStderrEmitted = false;
17
+ pipeMergeHintEmitted = false;
18
+ }
19
+ /** Clear the write-face marker (call after the command finishes). */
20
+ export function endWriteFace() {
21
+ writeFaceActive = false;
22
+ humanStderrEmitted = false;
23
+ }
24
+ const PIPE_MERGE_HINT = "提示:本行在 stderr;stdout 仍是**恰好一个** JSON 信封。脚本解析请勿 2>&1 合流(会得到两段文本)。";
25
+ /**
26
+ * Append the 2>&1 warning once, only when stdout is piped, a write face ran,
27
+ * and at least one human stderr line was already emitted.
28
+ */
29
+ export function maybeWarnMergedStreams() {
30
+ if (pipeMergeHintEmitted || options.quiet)
31
+ return;
32
+ if (!writeFaceActive || !humanStderrEmitted)
33
+ return;
34
+ if (process.stdout.isTTY === true)
35
+ return;
36
+ pipeMergeHintEmitted = true;
37
+ process.stderr.write(`${PIPE_MERGE_HINT}\n`);
38
+ }
39
+ /** Test seam: the exact hint line (no trailing newline). */
40
+ export const PIPE_MERGE_HINT_FOR_TEST = PIPE_MERGE_HINT;
5
41
  /** Parse `--quiet`, `--verbose` and `--out <file>` from the raw argv once, before dispatch. */
6
42
  export function configureOutput(argv) {
7
43
  const outIndex = argv.indexOf("--out");
@@ -177,7 +213,10 @@ export function writeEnvelope(value) {
177
213
  }
178
214
  /** Emit a human diagnostic on stderr, unless `--quiet` asked for none. */
179
215
  export function humanSummary(line) {
180
- if (!options.quiet)
181
- process.stderr.write(`${line}\n`);
216
+ if (options.quiet)
217
+ return;
218
+ process.stderr.write(`${line}\n`);
219
+ humanStderrEmitted = true;
220
+ maybeWarnMergedStreams();
182
221
  }
183
222
  //# sourceMappingURL=cli-output.js.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Which CLI roots write their own stdout envelope (HANDOFF §三.2-6).
3
+ *
4
+ * 这是**显式声明**,不是散落在 `main()` 里的字符串数组。历史上两次踩同一个坑:
5
+ * - r10:composers(`form-default` / `compute` / `rule-when` / `approval-flow`)
6
+ * 不在名单里 → 真信封后又跟一个 `{"ok":true,"result":{}}`,读成第二个答案。
7
+ * - 0.6.7:`setup` 非 TTY 报错不在名单里 → 补了一个谎报 `internal` 的信封(B9)。
8
+ * - 2026-11-05:`submit-verify` / `rule-save-enable` / `workflow-update-publish`
9
+ * 三个复合命令自己 `writeEnvelope`,却仍不在名单里 → 参数 JSON 写坏时
10
+ * stdout 出两行信封(实测 2 行),破坏「stdout 恰好一个信封」契约。
11
+ *
12
+ * 语义只有三档:
13
+ * - `envelope`:自己写**一个**结果信封(主流程不得再补)。
14
+ * - `raw`:自己写非信封输出(`env --json` 的 JSON、`--version` 的版本行),
15
+ * 同样不得再补。
16
+ * - `stderr-only`:只往 stderr 写人类诊断,成功/失败都不产生信封(`doctor`、
17
+ * `bench`)。主流程也不补,否则会给这类命令安一个假的 `ok`/`internal`。
18
+ *
19
+ * 新增一个根命令时必须在这里登记;`test/cli-stdout-contract.test.ts` 会遍历
20
+ * `cli.ts` 里注册的全部根命令,漏登记直接失败。
21
+ */
22
+ export const CLI_STDOUT_OWNERS = {
23
+ // 工具执行与读面
24
+ call: "envelope",
25
+ script: "envelope",
26
+ artifact: "envelope",
27
+ commands: "envelope",
28
+ describe: "envelope",
29
+ contract: "envelope",
30
+ capabilities: "envelope",
31
+ capability: "envelope",
32
+ next: "envelope",
33
+ usage: "envelope",
34
+ intent: "envelope",
35
+ help: "envelope",
36
+ graph: "envelope",
37
+ programming: "envelope",
38
+ rollback: "envelope",
39
+ setup: "envelope",
40
+ // composers:自己合成结果信封
41
+ "form-default": "envelope",
42
+ compute: "envelope",
43
+ "rule-when": "envelope",
44
+ "approval-flow": "envelope",
45
+ "model-scaffold": "envelope",
46
+ "field-add": "envelope",
47
+ "model-purge": "envelope",
48
+ // 复合写命令(r19 tickets 11–13):两阶段回执是自己的信封
49
+ "submit-verify": "envelope",
50
+ "rule-save-enable": "envelope",
51
+ "workflow-update-publish": "envelope",
52
+ // 原始输出
53
+ env: "raw",
54
+ // MCP stdio 服务器:stdout 是 JSON-RPC 帧流,补一个信封会直接污染协议。
55
+ "mcp-graph": "raw",
56
+ "--version": "raw",
57
+ "-V": "raw",
58
+ // 人类体检 + 一个结构化回执(checks / unsupportedCapabilities 在 stdout)
59
+ doctor: "envelope",
60
+ // bench 只往 stderr 写报告,不产生信封
61
+ bench: "stderr-only",
62
+ };
63
+ /** 主流程绝不能在这些命令之后再补写一个信封。 */
64
+ export function ownsStdout(name) {
65
+ return Object.hasOwn(CLI_STDOUT_OWNERS, name);
66
+ }
67
+ /**
68
+ * 已登记的命令里,哪些**不**写信封——主流程连空的 `ok` 都不该补。
69
+ * (`ownsStdout` 已覆盖它们;这个集合只是为了把「为什么没有信封」写成代码。)
70
+ */
71
+ export function isStderrOnlyCommand(name) {
72
+ return ownsStdout(name) && CLI_STDOUT_OWNERS[name] === "stderr-only";
73
+ }
74
+ //# sourceMappingURL=cli-stdout.js.map