@stalfh233/omc-cli 0.4.79 → 0.4.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,10 +130,13 @@ omc call discover_environment --env <env> --args '{}' # 环境就绪(真实
130
130
  - **intent**:`partial` 表示该能力可声明式(`omc intent`)覆盖一部分——通常 create/update;
131
131
  `none` 表示只走 `omc call`。删除类、方法面、分类/连接池等仍走 `omc call` 逃生舱。
132
132
 
133
- ## 近期能力(0.4.74 → 0.4.78)
133
+ ## 近期能力(0.4.74 → 0.4.81)
134
134
 
135
135
  | 版本 | 新增 / 修复 |
136
136
  |---|---|
137
+ | 0.4.81 | **r11 真机探针修复**:`rule.save` 对 `AGGREGATION` 规则节点**写前 fail-closed**(平台会存进去、运行时 50000,真实路径是 `omc compute`/datarule type2);`omc next "统计子表行数"` 路由补 `datarule` 阶梯与 `field-compute-subtable-sum` 配方;`org.user.search` 省略关键字时**按部门树列全员**(平台 `wd` 不支持空串,此前只能枚举 0–9);`omc artifact get --jq` 无值回键清单(不再 commander 报错);`usage` 的 `faces` 纳入 `--jq/--summary/--keys/--path-only/--extract`(此前读面投影完全不记账) |
138
+ | 0.4.80 | **省 token 与用量账本**:`omc help <topic>` 默认只回节索引(约 1KB,含每节字节数),`--all` 才读整篇;`omc artifact get` 正文 >8KB 默认只回形状(`--full` 才整包);`omc usage` 默认只回汇总(`byTool`/`redundancy`/`estimatedTokens`),`--records` 才展开;`omc script` 的**每行工具调用单独记账**(`script-call` + `parentId`);未绑定环境的目录回落 `$OMC_CONFIG_HOME/usage/<cwd-hash>.jsonl` |
139
+ | 0.4.79 | README 对齐 0.4.79 实际工具面(142 工具 / 16 能力) |
137
140
  | 0.4.78 | **按钮 JS 直接读写**:`listview.get --buttonCode/--buttons`、`listview.configure --buttonScripts`、`form.get --buttonScripts`、`form.publish --buttonScripts`,外加 `buttons[].script` 简写 |
138
141
  | 0.4.77 | **应用目录(分组)**:`folder.tree/create/update/delete` + `model.move`(`bizmodels/update` 换 parentId,跨应用目录单独报错) |
139
142
  | 0.4.76 | **读面一键取值**:`call --extract <path>`(可重复)/`--extract-keys`,省掉一次 `artifact get`;`form.draft fromReadback:true` 允许平台原样布局回写;`--jq` 支持数组选择器 `[code=X]` |
@@ -220,11 +223,13 @@ omc intent readback intent.yaml --env <env> # 解码回 intent 形状做等价
220
223
 
221
224
  | 命令 | 作用 |
222
225
  |---|---|
223
- | `omc artifact get <id> [--summary\|--keys\|--jq <path>]` | 取回大读回的 `detailFile` 工件(0600,校验 sha256) |
226
+ | `omc artifact get <id> [--summary\|--keys\|--jq <path>\|--full]` | 取回大读回的 `detailFile` 工件(0600,校验 sha256)。**正文超 8KB 默认只回形状**(`outline:true` + 顶层键与体积 + `keysCommand`/`fullCommand`);`--jq`/`--keys`/`--summary` 定向取,`--full` 才整包 |
227
+ | `omc help <topic>` | 方法论 topic:**默认只回节索引**(`{heading, bytes}`,约 1KB);`--section "<标题>"`(可重复/逗号多节/序号/唯一前缀)取正文;`--all` 取整篇 |
224
228
  | `omc call <tool> … --extract <path>` | **一步取值**:在本次结果的 artifact 内解析路径并直接回值(可重复给多个路径);`--extract-keys` 列顶层键与体积。路径支持 `.rules[code=Create].name` 这种数组选择器,免掉一次 `artifact get` |
225
229
  | `omc setup [--list\|--edit <env>\|--remove <env>]` | 命名环境的增删改查 |
226
230
  | `omc doctor [--environment <env>]` | 只报告的健康链 |
227
231
  | `omc bench [--live-sample] [--env <env>]` | 工具面/wire 面的双面埋点跑分(离线 fake 或 live 采样) |
232
+ | `omc usage [--top <n>] [--records]` | 用量账本:默认只回汇总(调用数/字节/`redundancy` 重复浪费/`byTool` 排名/`estimatedTokens`≈字节/4);`--records` 才回原始记录。数据源 `.omc/usage.jsonl`(每条 `omc` 调用一行,`omc script` 的每行工具调用另记 `script-call`,带 `parentId`) |
228
233
  | `npm run eval:discovery-cost --workspace omc-cli` | 三组离线 discovery 成本基线(调用次数、stdout 字节、拒绝/重试、CloudPivot 写入) |
229
234
 
230
235
  ## 覆盖边界
@@ -210,7 +210,14 @@ export const CAPABILITY_CUES = Object.freeze({
210
210
  // 日期) because no cue named the VALIDATION intent, so the agent went to the
211
211
  // rule graph and concluded "the platform has no today variable" — wrong, and
212
212
  // the form designer's 提交校验 (datarule type1/checkType2) is the real face.
213
+ // r11 (2026-10-24): "统计子表行数聚合 COUNT 写入主表字段" routed to `field`
214
+ // (it matched 子表+字段) and answered with the "create a child table" recipe,
215
+ // so the agent hand-built an AGGREGATION rule node — a shape the platform
216
+ // accepts and then fails at runtime. Row aggregation is a FIELD COMPUTE RULE
217
+ // (`omc compute` / datarule type2). These compound cues outscore field's
218
+ // 子表+字段 so the aggregate intent reaches the right ladder.
213
219
  datarule: ["字段级规则", "控件规则", "校验规则", "计算拼接", "显示规则", "规则", "type1", "type2", "type3",
220
+ "统计子表", "子表行数", "子表计数", "子表求和", "子表汇总", "行数统计", "计数写回", "汇总回主表", "求和回主表", "聚合计算",
214
221
  "不早于", "不能早于", "不晚于", "不能晚于", "早于今天", "晚于今天", "日期校验", "提交校验", "校验不通过", "必填校验", "正则校验", "重复校验", "长度校验"],
215
222
  // NOTE: bare "自动" was a cue (round 10 bug: "自动识别"/"自动发邮件" matched
216
223
  // businessrule and routed an UNSUPPORTED ask onto the write ladder). A business
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { readArgsFile, readStdinArgs, runToolCall, validateAgentPaths } from "./
14
14
  import { environmentWritable, requiresProductionConfirmation } from "./environment-policy.js";
15
15
  import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, requestId } from "./cli-contract.js";
16
16
  import { configureOutput, humanSummary, stripOutputFlags, writeEnvelope } from "./cli-output.js";
17
- import { beginUsage, completeUsage, recordUsageOutput, usageReport } from "./usage-log.js";
17
+ import { beginUsage, completeUsage, ensureProjectIgnore, recordUsageOutput, usageReport } from "./usage-log.js";
18
18
  import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
19
19
  import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath, pruneArtifacts } from "./artifacts.js";
20
20
  import { projectJsonPath, projectResultExtract } from "./path-projection.js";
@@ -36,7 +36,7 @@ import { absencePointer } from "./capability-absences.js";
36
36
  import { intentConfigRoot, readIntentFile, validateIntentText } from "./intent/cli.js";
37
37
  import { intentSchemaBlock, intentExample, intentExampleIds } from "./intent/schema.js";
38
38
  import { planIntent, readbackIntent, applyIntent, intentWarnings } from "./intent/plan-apply.js";
39
- import { listSkills, readSkill, readSkillSection } from "./skills.js";
39
+ import { listSkills, readSkill, readSkillOutline, readSkillSection } from "./skills.js";
40
40
  import { parseToolInput, ToolInputError } from "./tool-types.js";
41
41
  import { WireError, wireCall } from "./wire.js";
42
42
  import { ENDPOINTS } from "./endpoints.js";
@@ -115,8 +115,17 @@ async function cmdSetup(store, args) {
115
115
  if (username && password)
116
116
  store.writeCredential(name, { username, password });
117
117
  const bind = (await ask("将本项目(.omc-env)绑定到该环境?[y/N]:")).toLowerCase() === "y";
118
- if (bind)
118
+ if (bind) {
119
119
  store.bindProjectEnvironment(name);
120
+ // Binding opts the directory into `.omc/` (artifacts + usage log), so keep
121
+ // that directory out of version control. Only an existing .gitignore is
122
+ // touched: creating VCS files unasked is worse than one warning line.
123
+ const ignore = ensureProjectIgnore();
124
+ if (ignore === "appended")
125
+ console.error("已把 .omc/ 追加到 .gitignore(用量账本与产物不纳入版本控制)");
126
+ else if (ignore === "no-gitignore")
127
+ console.error("提示:本项目没有 .gitignore;建议添加一行 .omc/(否则 .omc/ 的用量账本可能被提交)");
128
+ }
120
129
  console.error(`环境 ${name} 已保存(幂等,可随时重跑 omc setup)。凭据存储模式:${store.credentialsMode()}`);
121
130
  console.error(`安全建议:优先使用专用集成账号(只读+目标应用写权限)而非系统管理员账号;密码仅存于本机 ~/.config/omc/credentials(0600),请勿在对话或文档中传递。`);
122
131
  return 0;
@@ -460,6 +469,12 @@ function parseDurationMs(text) {
460
469
  const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : unit === "d" ? 86_400_000 : 604_800_000;
461
470
  return amount * factor;
462
471
  }
472
+ /**
473
+ * Above this size `omc artifact get` returns the outline, not the body. The
474
+ * transport cap (64 KB) is far too generous for a working context: one 27 KB
475
+ * template re-read three times is 81 KB of the session.
476
+ */
477
+ const ARTIFACT_INLINE_CEILING_BYTES = 8192;
463
478
  function formatBytes(bytes) {
464
479
  if (bytes < 1024)
465
480
  return `${bytes}B`;
@@ -531,14 +546,37 @@ async function cmdArtifactGet(id, transport, options = {}) {
531
546
  }
532
547
  // r6-onboarding 2026-10-21: 47 artifact fetches, 0 used a projection; the 10
533
548
  // large ones (up to 55 KB) pulled whole workflow templates just to read one
534
- // nested key. Nudge once, in the same response, instead of hoping the hint
535
- // in `fetch` is noticed.
536
- const unprojected = artifact.content.length > 8192
537
- ? { hint: `artifact ${Math.round(artifact.content.length / 1024)}KB 已整包返回。只要其中一段时用 --jq '<path>'(或 --summary 看形状、--keys 取顶层键),不要把整包带进上下文。` }
538
- : {};
539
- const result = { status: "ok", artifactId: id, sha256: artifact.sha256, content, ...unprojected };
549
+ // nested key. Round 10 still spent 247 KB (22.7% of the session) on 28
550
+ // `artifact get` calls, re-fetching 10–27 KB bodies — the nudge below was
551
+ // advisory and did not change behaviour.
552
+ //
553
+ // 0.4.80: shape first, the same rule as `omc help`. Above the inline ceiling
554
+ // the default is the outline (top-level keys + their sizes) plus runnable
555
+ // projections; `--full` returns the whole body exactly as before. Explicit
556
+ // projections (--summary/--keys/--jq/--path-only) are handled above and are
557
+ // never affected.
558
+ if (options.full !== true && artifact.content.length > ARTIFACT_INLINE_CEILING_BYTES) {
559
+ const record = content && typeof content === "object" && !Array.isArray(content) ? content : undefined;
560
+ const keys = record
561
+ ? Object.entries(record)
562
+ .map(([key, value]) => ({ key, bytes: Buffer.byteLength(JSON.stringify(value) ?? "null", "utf8"), kind: Array.isArray(value) ? `array[${value.length}]` : value === null ? "null" : typeof value }))
563
+ .sort((left, right) => right.bytes - left.bytes)
564
+ : [];
565
+ return emitTransportResult({
566
+ status: "ok",
567
+ artifactId: id,
568
+ sha256: artifact.sha256,
569
+ bytes: artifact.content.length,
570
+ outline: true,
571
+ ...(record ? { keys } : { kind: typeof content }),
572
+ hint: `正文 ${Math.round(artifact.content.length / 1024)}KB 超过内联上限 ${ARTIFACT_INLINE_CEILING_BYTES}B,本次只回形状(顶层键 + 体积)。要某几个键:--keys <a,b>;要某个路径:--jq '<path>'(支持 .rules[code=Create].name 这类数组选择器);确实需要整包:--full。`,
573
+ keysCommand: keys.length > 0 ? `omc artifact get ${id} --keys ${keys.slice(0, 3).map((entry) => entry.key).join(",")}` : `omc artifact get ${id} --summary`,
574
+ fullCommand: `omc artifact get ${id} --full`,
575
+ }, transport);
576
+ }
577
+ const result = { status: "ok", artifactId: id, sha256: artifact.sha256, content };
540
578
  if (Buffer.byteLength(JSON.stringify(result), "utf8") > transport.maxOutputBytes)
541
- return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, ...unprojected, fetch: artifactFetchHint(id) }, transport);
579
+ return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, fetch: artifactFetchHint(id) }, transport);
542
580
  return emitTransportResult(result, transport);
543
581
  }
544
582
  catch (error) {
@@ -692,8 +730,8 @@ function cmdDiscovery(kind, transport, value, options = {}) {
692
730
  }
693
731
  return emitTransportResult({ status: "ok", ...result }, transport);
694
732
  }
695
- function cmdUsage(transport) {
696
- return emitTransportResult(usageReport(), transport);
733
+ function cmdUsage(transport, options = {}) {
734
+ return emitTransportResult(usageReport(undefined, { records: options.records === true, top: options.top, resolveLikeWriter: true }), transport);
697
735
  }
698
736
  /**
699
737
  * Round 3, module A — `omc next "<goal>"`: the goal → route solver. Read-only,
@@ -853,7 +891,7 @@ function cmdIntentSchema(transport, example, exampleRequested) {
853
891
  * like `git help <concept>`. The command list is the "what"; these topics are
854
892
  * the "how".
855
893
  */
856
- function cmdHelp(topic, commandNames, transport, section, sections = false) {
894
+ function cmdHelp(topic, commandNames, transport, section, sections = false, all = false) {
857
895
  const topics = listSkills();
858
896
  const commands = [...commandNames].sort();
859
897
  // Several sections in one call: `--section a --section b` or `--section a,b`.
@@ -943,8 +981,31 @@ function cmdHelp(topic, commandNames, transport, section, sections = false) {
943
981
  console.error(`help: ${message}`);
944
982
  return emitInvalid(transport, { message });
945
983
  }
946
- console.error(`omc help ${document.name}`);
947
- return emitTransportResult({ status: "ok", topic: document }, transport);
984
+ if (all) {
985
+ console.error(`omc help ${document.name} --all(${Buffer.byteLength(document.content, "utf8")} B)`);
986
+ return emitTransportResult({ status: "ok", topic: document }, transport);
987
+ }
988
+ // Shape first. Round 10 paid 35,872 B per full read of one topic, seven times
989
+ // in a single session, to use one or two headings. The index names every
990
+ // heading AND its price, so the caller can pick a section (or ask for --all)
991
+ // instead of buying the whole document by default.
992
+ const outline = readSkillOutline(topic);
993
+ if (outline === undefined)
994
+ return emitInvalid(transport, { message: `未知 topic "${topic}";先用 omc help topics 查看可用 topic` });
995
+ console.error(`omc help ${topic}:${outline.sections.length} 节 / 全文 ${outline.bytes} B(默认只回索引;--section 取正文,--all 取全文)`);
996
+ return emitTransportResult({
997
+ status: "ok",
998
+ topic: outline.document.name,
999
+ bytes: outline.bytes,
1000
+ sectionCount: outline.sections.length,
1001
+ // Each section carries its price inline. `{"heading":"入口","bytes":282}`
1002
+ // is cheaper than parallel arrays and reads better than a heading→bytes map;
1003
+ // the heading names are what `--section` accepts.
1004
+ sections: outline.sections.map((entry) => ({ heading: entry.heading, bytes: entry.bytes })),
1005
+ hint: "默认只回节索引:先按各节 bytes 挑要读的节,再用 --section 取正文(可重复或逗号一次多节,也可用节序号或唯一前缀)。确需整篇才 --all。",
1006
+ sectionCommand: `omc help ${outline.document.name} --section "<heading>"`,
1007
+ fullCommand: `omc help ${outline.document.name} --all`,
1008
+ }, transport);
948
1009
  }
949
1010
  /**
950
1011
  * The text every agent should see when it lands on `omc --help`, `omc` with no
@@ -955,8 +1016,9 @@ function cmdHelp(topic, commandNames, transport, section, sections = false) {
955
1016
  function onboardingHelp() {
956
1017
  return [
957
1018
  "首次使用、或不确定从哪开始:omc help omc-start-here(随包权威 topic 路线);被拒或状态异常:omc help omc-refusals。",
1019
+ "方法论按需读:omc help <topic> 默认只回节索引(每节字节数);用 --section \"<标题>\" 取正文(可重复),确需整篇才 --all。",
958
1020
  "目标→工具一步定位:omc next \"<你要做什么>\"(离线、只读,给能力/说明/首批命令;不确定会明说并回退到 omc-requirement-analysis)。",
959
- "大结果取值一步到位:读面回 detailArtifactId 时,用 omc call <tool> … --extract <path>(可重复)或 --extract-keys,省掉再跑一次 omc artifact get。",
1021
+ "大结果取值一步到位:读面回 detailArtifactId 时,用 omc call <tool> … --extract <path>(可重复)或 --extract-keys,省掉再跑一次 omc artifact get;artifact get 正文超 8KB 默认也只回形状(--jq/--keys 定向取,--full 才整包)。",
960
1022
  "工具发现:omc commands --json(含 capability id/lane/recipes;加 --compact 只回工具行、无目录散文)· omc capabilities(能力 id/lane/aliases/tools/依赖顺序)· omc capability <id> --executable(单能力完整执行包)。",
961
1023
  "单工具/契约:omc describe <tool>(schema+recipe+能力路由)· omc contract <lane>(wire 契约+能力路由);环境就绪:omc call discover_environment --env <env> --args '{}'。",
962
1024
  "写入两道门:对话层草稿确认(omc help omc-config-draft-review)+ 环境 writable 开关;生产环境每笔写需在 args 里带 confirmProduction:true。",
@@ -1035,7 +1097,7 @@ async function main() {
1035
1097
  command.outputHelp();
1036
1098
  return CLI_EXIT_CODES.ok;
1037
1099
  }
1038
- 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)); });
1100
+ command.command("help [topic]").option("--section [heading]", "按标题取该节内容;可重复或用逗号一次取多节:--section a --section b / --section a,b(不带值则列出该 topic 的节标题)", (value, previous) => [...previous, value], []).option("--sections").option("--all", "回整篇正文(默认只回节索引:标题 + 各节字节)").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, options.all === true)); });
1039
1101
  command.command("setup").option("--list").option("--edit <environment>").option("--remove <environment>").action(async (options) => {
1040
1102
  commandExitCode = await cmdSetup(new ConfigStore(defaultPaths()), [...(options.list ? ["--list"] : []), ...(options.edit ? ["--edit", options.edit] : []), ...(options.remove ? ["--remove", options.remove] : [])]);
1041
1103
  });
@@ -1169,8 +1231,13 @@ async function main() {
1169
1231
  console.error(`已清理 ${result.removed} 个读回产物,释放 ${formatBytes(result.freedBytes)};保留 ${result.kept} 个(${formatBytes(result.keptBytes)})`);
1170
1232
  commandExitCode = CLI_EXIT_CODES.ok;
1171
1233
  });
1172
- artifactCommand.command("get <id>").option("--jq <path>").option("--path-only").option("--summary").option("--keys [a,b]").option("--max-output-bytes <bytes>").action(async (id, options) => {
1173
- 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 } : {}) }));
1234
+ artifactCommand.command("get <id>").option("--jq [path]").option("--path-only").option("--summary").option("--keys [a,b]").option("--full", "整包返回正文(默认:正文超过 8KB 时只回形状 outline + fetch,按需 --jq/--keys 定向取值)").option("--max-output-bytes <bytes>").action(async (id, options) => {
1235
+ // A bare `--jq`/`--keys` is a key-inventory request, not a missing argument:
1236
+ // commander's "argument missing" (invalid-input + retry) is a dead end the
1237
+ // caller cannot fix without already knowing the flag takes a value (r11
1238
+ // burned six rounds on `artifact get <id> --jq` before trying `--summary`).
1239
+ const bareJq = options.jq === true;
1240
+ commandExitCode = await withTransport(options, request, (transport) => cmdArtifactGet(id, transport, { ...(typeof options.jq === "string" ? { jq: options.jq } : {}), pathOnly: options.pathOnly === true, summary: options.summary === true || options.keys === true || bareJq, ...(typeof options.keys === "string" ? { keys: options.keys } : {}), full: options.full === true }));
1174
1241
  });
1175
1242
  command.command("bench").option("--live-sample").option("--env <environment>").action(async (options) => { commandExitCode = await cmdBench([...(options.liveSample ? ["--live-sample"] : []), ...(options.env ? ["--env", options.env] : [])]); });
1176
1243
  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 } : {}) })); });
@@ -1185,8 +1252,12 @@ async function main() {
1185
1252
  command.command("next <goal>").option("--env <environment>").option("--max-output-bytes <bytes>").action(async (goal, options) => {
1186
1253
  commandExitCode = await withTransport(options, request, (transport) => cmdNext(goal, transport));
1187
1254
  });
1188
- command.command("usage").option("--max-output-bytes <bytes>").action(async (options) => {
1189
- commandExitCode = await withTransport(options, request, (transport) => cmdUsage(transport));
1255
+ command.command("usage").option("--records").option("--top <n>").option("--max-output-bytes <bytes>").addHelpText("after", "\n用量账本(.omc/usage.jsonl):默认只回汇总(调用数/字节/重复浪费/按工具 Top N);--records 才回原始记录。\n").action(async (options) => {
1256
+ const parsedTop = options.top === undefined ? undefined : Number.parseInt(String(options.top), 10);
1257
+ commandExitCode = await withTransport(options, request, (transport) => cmdUsage(transport, {
1258
+ records: options.records === true,
1259
+ ...(Number.isFinite(parsedTop) && parsedTop > 0 ? { top: parsedTop } : {}),
1260
+ }));
1190
1261
  });
1191
1262
  command.command("capability <id>").option("--executable").option("--max-output-bytes <bytes>").action(async (id, options) => {
1192
1263
  commandExitCode = await withTransport(options, request, (transport) => cmdCapability(id, transport, options.executable === true));
@@ -286,7 +286,7 @@ function sectionBytes(pointer) {
286
286
  const resolved = readSkillSection(pointer.topic, pointer.section);
287
287
  return resolved ? Buffer.byteLength(resolved.section, "utf8") : 0;
288
288
  }
289
- const READ_HINT = '按 readCommands 一次读完(每条命令用可重复的 `--section` 取该 topic 的所有相关节,等价于逐节 `omc help <topic> --section "<section>"` 但只花一次往返);单节字节数见 readSectionBudget,按预算自选。除非确需全貌,不要 `omc help <topic>` 读整篇(5–14KB,读一次就留在后续每一轮)。';
289
+ const READ_HINT = '按 readCommands 一次读完(每条命令用可重复的 `--section` 取该 topic 的所有相关节,等价于逐节 `omc help <topic> --section "<section>"` 但只花一次往返);单节字节数见 readSectionBudget,按预算自选。`omc help <topic>` 默认只回节索引(每节带字节数,约 1KB),不要再用 `--all` 读整篇(5–14KB,读一次就留在后续每一轮)。';
290
290
  function orderedCapabilities(ids, index) {
291
291
  const selected = new Set(ids);
292
292
  const nodes = [...index.nodeById.values()];
@@ -1225,6 +1225,20 @@ export function validateDataAction(action, nodeCode, index) {
1225
1225
  const blockers = [];
1226
1226
  const where = `dataActions[${index}]`;
1227
1227
  const valueType = String(action.ruleValueType ?? "");
1228
+ // 1.2 (r11 2026-10-24, 1.1.25): AGGREGATION on a rule node is a TRAP. The
1229
+ // platform accepts it (save + publish + enable all answer ok) and then throws
1230
+ // at runtime: the aggregate comes back as java.util.ArrayList and the scalar
1231
+ // target field rejects it (DecimalBizField.fillClientContent → 50000 服务异常).
1232
+ // Row aggregation is a FORMULA(100) FIELD feature, not a rule action. The r11
1233
+ // probe burned 6 rule.save + 4 data.submit + 3 docker-log reads before finding
1234
+ // the real path, and the only symptom the CLI ever showed was "服务异常".
1235
+ if (valueType.toUpperCase() === "AGGREGATION")
1236
+ return [{
1237
+ code: "rule-graph-aggregation-is-a-field-feature",
1238
+ nodeCode,
1239
+ key: `dataActions[${index}].ruleValueType`,
1240
+ message: `${nodeCode}: 规则节点不支持 AGGREGATION 赋值——平台会存进去并启用,但运行期聚合结果是集合(ArrayList),标量目标收到集合即 50000「服务异常」(DecimalBizField.fillClientContent)。**子表计数/求和/取最值回主表用字段计算规则**:目标字段建成普通数值字段,然后 \`omc compute --schema <主表> --target <目标字段> --formula 'COUNTIF({<子表code>.<子列code>},"异常")'\`(默认 dry-run,--apply 写入;等价 datarule.create dataRuleType:2,配方 \`field-compute-subtable-sum\`)。规则图里只做 FIXED/DYNAMIC/EXPRESSION 的直接赋值。注意计算由门户前端执行、API data.load 不回填(平台既有行为,见 FIELD_FORMULA_WIRE.runtimeVerification):要 API 侧断言结果,请在测试数据里显式写预算值,不要用回读判定公式对错。来源:r11 巡检沙盒 2026-10-24(1.1.25)。`,
1241
+ }];
1228
1242
  if (!RULE_VALUE_TYPES.includes(valueType))
1229
1243
  blockers.push({ code: "rule-graph-action-value-type", nodeCode, key: "ruleValueType", message: `${where}: ruleValueType "${valueType}" 不在 ${RULE_VALUE_TYPES.join("/")}` });
1230
1244
  if (typeof action.targetDataItem !== "string" || !action.targetDataItem.trim())
@@ -192,7 +192,7 @@ export const WORKFLOW_LANE_CONTRACT = Object.freeze({
192
192
  }),
193
193
  "org.user.search": Object.freeze({
194
194
  scope: "workflow participant resolution",
195
- description: "按关键字搜索用户(`POST /api/api/organization/user/search`,body `{wd,page,size,filterType?}`;`wd` 必填,缺失报 10005 关键字不能为空;**空字符串也被工具参数校验拒绝(`String must contain at least 1 character`),无法用它列全员——列某部门的人请用 `org.department.users`,全量检索没有读面**),返回 {id,name,username,userId,departmentId,corpId,status}。用于把「张三」这类姓名解析成真实用户 id/username 再构造参与者对象(`[{\"code\":\"<username|userId>\",\"corpId\":\"<corpId|other>\",\"id\":\"<userId>\",\"name\":\"<姓名>\",\"type\":3}]`,type 3=用户 / 2=角色 / 多个用 `+` 连接);角色用 org.role.list、部门用 org.department.list。",
195
+ description: "按关键字搜索用户(`POST /api/api/organization/user/search`,body `{wd,page,size,filterType?}`,返回 {id,name,username,userId,departmentId,corpId,status})。平台 `wd` 必填、空串报 10005,所以**省略 wd 时不打 user/search**:工具改为遍历部门树(`organization/department/tree` + `department/list_user`)聚合成全员清单(返回 `keyword:null` 与 `departmentsScanned`;>200 部门会带 `truncated`)。用于把「张三」解析成真实用户 id/username 再构造参与者对象(`[{\"code\":\"<username|userId>\",\"corpId\":\"<corpId|other>\",\"id\":\"<userId>\",\"name\":\"<姓名>\",\"type\":3}]`,type 3=用户 / 2=角色 / 多个用 `+` 连接);只要某部门的人用 org.department.users,角色用 org.role.list。",
196
196
  }),
197
197
  "workitem.list": Object.freeze({
198
198
  scope: "runtime workflow work-item query",
@@ -2819,9 +2819,9 @@ export function registerWorkflowLane(tools) {
2819
2819
  // `wd` is the platform's own key; `keyword`/`q` are accepted aliases so a
2820
2820
  // caller that reaches for the natural word is not bounced with
2821
2821
  // "wd: Required" (round 10: an agent sent `keyword` and burned a retry).
2822
- wd: z.string().min(1).optional().describe("搜索关键字(用户姓名或账号用户名);平台 \"关键字不能为空\" 即缺它。可用 keyword/q 作别名"),
2823
- keyword: z.string().min(1).optional().describe("`wd` 的别名"),
2824
- q: z.string().min(1).optional().describe("`wd` 的别名"),
2822
+ wd: z.string().optional().describe("搜索关键字(用户姓名或账号用户名);**省略则列出全部员工**(工具按部门树聚合,见 description)。可用 keyword/q 作别名"),
2823
+ keyword: z.string().optional().describe("`wd` 的别名"),
2824
+ q: z.string().optional().describe("`wd` 的别名"),
2825
2825
  page: z.number().int().min(0).optional().describe("页号,默认 0"),
2826
2826
  size: z.number().int().min(1).optional().describe("每页条数,默认 100"),
2827
2827
  filterType: z.string().optional().describe("admin=只搜当前应用管理员可见范围(可省)"),
@@ -2829,8 +2829,71 @@ export function registerWorkflowLane(tools) {
2829
2829
  },
2830
2830
  handler: async (ctx, args, self) => {
2831
2831
  const keyword = args.wd ?? args.keyword ?? args.q;
2832
- if (!keyword)
2833
- throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "org.user.search", message: "需要 wd(关键字);keyword/q 亦可" });
2832
+ // 0.4.81 (r11 2026-10-24): no keyword used to be a hard refusal, and the
2833
+ // platform rejects an empty `wd` (10005), so the r11 probe enumerated
2834
+ // `wd` = 0..9 to discover users — 11 calls, ~16 KB, to learn a roster the
2835
+ // platform can already answer. Omitted keyword now aggregates
2836
+ // `department/list_user` over the department tree (one tool call).
2837
+ if (!keyword) {
2838
+ return governedRead(ctx, self, args, async (session) => {
2839
+ const orgQuery = { corpId: session.enterpriseId() };
2840
+ const seenDepartments = new Set();
2841
+ const order = [];
2842
+ const pushRows = (rows) => {
2843
+ for (const row of rows) {
2844
+ const id = String(row.id ?? "");
2845
+ if (id === "" || seenDepartments.has(id))
2846
+ continue;
2847
+ seenDepartments.add(id);
2848
+ order.push(id);
2849
+ }
2850
+ };
2851
+ // `/department/tree` answers `{departmentList:[...]}`, not a bare array;
2852
+ // `wireRows` only understands arrays/page envelopes.
2853
+ const treeRows = async (deptId) => {
2854
+ const data = (await wireCall(session, {
2855
+ path: ENDPOINTS.organizationDepartmentTree,
2856
+ query: deptId === undefined ? orgQuery : { ...orgQuery, deptIds: deptId },
2857
+ })).data;
2858
+ return Array.isArray(data?.departmentList) ? data.departmentList : [];
2859
+ };
2860
+ pushRows(await treeRows());
2861
+ // `deptIds` returns the subtree BELOW that department; walk the roots.
2862
+ for (const rootId of [...order]) {
2863
+ if (order.length >= 200)
2864
+ break;
2865
+ pushRows(await treeRows(rootId));
2866
+ }
2867
+ const seenUsers = new Map();
2868
+ let departmentsScanned = 0;
2869
+ for (const deptId of order.slice(0, 200)) {
2870
+ departmentsScanned += 1;
2871
+ for (const row of wireRows((await wireCall(session, { path: ENDPOINTS.organizationDepartmentUsers, query: { ...orgQuery, deptId } })).data)) {
2872
+ const id = String(row.id ?? row.userId ?? "");
2873
+ if (id === "" || seenUsers.has(id))
2874
+ continue;
2875
+ seenUsers.set(id, {
2876
+ id: row.id ?? row.userId ?? null,
2877
+ name: row.name ?? null,
2878
+ username: row.username ?? null,
2879
+ userId: row.userId ?? null,
2880
+ departmentId: row.departmentId ?? deptId,
2881
+ mobile: row.mobile ?? null,
2882
+ });
2883
+ }
2884
+ }
2885
+ const users = [...seenUsers.values()];
2886
+ return {
2887
+ keyword: null,
2888
+ count: users.length,
2889
+ total: users.length,
2890
+ departmentsScanned,
2891
+ ...(order.length > 200 ? { truncated: true, note: `部门树超过 200 个,只聚合了前 200 个;用 wd 关键字精确检索` } : {}),
2892
+ users,
2893
+ hint: "无关键字=列全员(按部门树聚合)。平台 user/search 的 wd 不支持空串(10005),所以这条路径走 department/list_user;只要某部门的人用 org.department.users。",
2894
+ };
2895
+ });
2896
+ }
2834
2897
  return governedRead(ctx, self, { ...args, wd: keyword }, async (session) => {
2835
2898
  const body = { wd: keyword, page: args.page ?? 0, size: args.size ?? 100 };
2836
2899
  if (args.filterType !== undefined)
package/dist/script.js CHANGED
@@ -4,6 +4,7 @@ import { ConfigStore, defaultPaths } from "./config-store.js";
4
4
  import { SessionManager } from "./session-manager.js";
5
5
  import { makeToolContext } from "./tools.js";
6
6
  import { runToolCall } from "./call.js";
7
+ import { currentUsageId, recordScriptCall } from "./usage-log.js";
7
8
  export function parseScript(text) {
8
9
  const lines = [];
9
10
  const raw = text.split("\n");
@@ -36,8 +37,14 @@ export function parseScript(text) {
36
37
  export async function runScript(lines, options = {}) {
37
38
  const print = options.print ?? ((line) => console.log(line));
38
39
  const context = options.context ?? makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
40
+ // The outer `script` invocation is already recorded by completeUsage; these
41
+ // inner records are what make a batch measurable at all. Before this, all 23
42
+ // `script -` calls in round 10 logged stdoutBytes=0 and the actual work was
43
+ // invisible (the outer record says "script -", the log says nothing else).
44
+ const parentId = currentUsageId();
39
45
  let failed = false;
40
46
  for (const [index, line] of lines.entries()) {
47
+ const startedMs = Date.now();
41
48
  const { exitCode, result } = await runToolCall(line.tool, {
42
49
  args: line.args,
43
50
  environment: line.env ?? options.defaultEnv,
@@ -46,7 +53,18 @@ export async function runScript(lines, options = {}) {
46
53
  context,
47
54
  print: () => undefined,
48
55
  });
49
- print(JSON.stringify({ index, tool: line.tool, exitCode, result }));
56
+ const rendered = JSON.stringify({ index, tool: line.tool, exitCode, result });
57
+ recordScriptCall({
58
+ ...(parentId ? { parentId } : {}),
59
+ index,
60
+ tool: line.tool,
61
+ ...(line.args ? { args: line.args } : {}),
62
+ exitCode,
63
+ result,
64
+ stdoutBytes: Buffer.byteLength(rendered, "utf8"),
65
+ durationMs: Date.now() - startedMs,
66
+ });
67
+ print(rendered);
50
68
  if (exitCode !== 0) {
51
69
  failed = true;
52
70
  if (options.continueOnError !== true) {
package/dist/skills.js CHANGED
@@ -52,16 +52,45 @@ export function readSkill(name) {
52
52
  return undefined;
53
53
  }
54
54
  }
55
+ /**
56
+ * Headings that are really headings. The naive `^#{1,6}` scan also matched
57
+ * shell/JS comments inside fenced code blocks — `# --env 在单环境项目...` in
58
+ * omc-business-configuration was listed as an H1 section, which both polluted
59
+ * the section list and (since it was a second H1) hid every H2 from the index.
60
+ * Fenced blocks (``` and ~~~) are skipped, per CommonMark: a closing fence is
61
+ * the same character and at least as long as the opening one.
62
+ */
63
+ function scanHeadings(lines) {
64
+ const found = [];
65
+ let fence;
66
+ for (const [index, line] of lines.entries()) {
67
+ const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
68
+ if (fenceMatch) {
69
+ const marker = fenceMatch[1];
70
+ if (fence === undefined)
71
+ fence = { char: marker[0], length: marker.length };
72
+ else if (marker[0] === fence.char && marker.length >= fence.length)
73
+ fence = undefined;
74
+ continue;
75
+ }
76
+ if (fence !== undefined)
77
+ continue;
78
+ const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
79
+ if (match)
80
+ found.push({ index, level: match[1].length, heading: match[2] });
81
+ }
82
+ return found;
83
+ }
55
84
  /** Return one Markdown heading and its nested content without loading the full topic into the envelope. */
56
85
  export function readSkillSection(name, requested) {
57
86
  const document = readSkill(name);
58
87
  if (!document)
59
88
  return undefined;
60
89
  const lines = document.content.split(/\r?\n/);
61
- const headings = lines.flatMap((line) => {
62
- const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
63
- return match ? [match[2]] : [];
64
- });
90
+ const scanned = scanHeadings(lines);
91
+ // The section LIST is what `--section` with no value returns: every real
92
+ // heading (any level), in document order.
93
+ const headings = scanned.map((entry) => entry.heading);
65
94
  const normalized = requested.trim().toLowerCase();
66
95
  // A requested section may be given by its ordered number ("8") instead of the
67
96
  // full heading ("8. Canonical configuration order(规范配置顺序)") — the
@@ -80,25 +109,19 @@ export function readSkillSection(name, requested) {
80
109
  }
81
110
  return false;
82
111
  };
83
- let start = lines.findIndex((line) => {
84
- const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
85
- return match !== null && matches(match[2].trim().toLowerCase());
86
- });
112
+ let start = scanned.find((entry) => matches(entry.heading.trim().toLowerCase()))?.index ?? -1;
87
113
  // Forgiving fallback: a unique prefix/substring match ("流程" for "流程字段权限").
88
114
  // A non-unique partial matches nothing (precision over recall) and the
89
115
  // not-found error lists the real headings, so the caller can retry once.
90
116
  if (start < 0 && normalized.length >= 2) {
91
- const partial = lines.filter((line) => {
92
- const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
93
- return match !== null && match[2].trim().toLowerCase().includes(normalized);
94
- });
117
+ const partial = scanned.filter((entry) => entry.heading.trim().toLowerCase().includes(normalized));
95
118
  if (partial.length === 1)
96
- start = lines.indexOf(partial[0]);
119
+ start = partial[0].index;
97
120
  }
98
121
  if (start < 0)
99
122
  return { document, section: "", headings };
100
- const startMatch = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[start]);
101
- const level = startMatch[1].length;
123
+ const startEntry = scanned.find((entry) => entry.index === start);
124
+ const level = startEntry.level;
102
125
  let end = lines.length;
103
126
  for (let index = start + 1; index < lines.length; index += 1) {
104
127
  const match = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[index]);
@@ -109,4 +132,42 @@ export function readSkillSection(name, requested) {
109
132
  }
110
133
  return { document, section: lines.slice(start, end).join("\n").trim() + "\n", headings };
111
134
  }
135
+ /**
136
+ * The "shape" of a topic: every heading with the bytes it would cost to read.
137
+ *
138
+ * Round 10 measured a single `help omc-business-configuration` at 35,872 B and
139
+ * seven such full reads in one session; the caller only ever needed one or two
140
+ * headings. An index costs ~600 B and names the price of each part, so the
141
+ * caller chooses instead of paying for the whole document by default.
142
+ */
143
+ export function readSkillOutline(name) {
144
+ const document = readSkill(name);
145
+ if (!document)
146
+ return undefined;
147
+ const lines = document.content.split(/\r?\n/);
148
+ const headings = scanHeadings(lines);
149
+ const sections = headings.map((entry) => {
150
+ let end = lines.length;
151
+ // Same slicing rule as readSkillSection: a section runs until the next
152
+ // heading of the same or a higher level, so reported bytes match the read.
153
+ for (let index = entry.index + 1; index < lines.length; index += 1) {
154
+ const match = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[index]);
155
+ if (match && match[1].length <= entry.level) {
156
+ end = index;
157
+ break;
158
+ }
159
+ }
160
+ const body = `${lines.slice(entry.index, end).join("\n").trim()}\n`;
161
+ return { heading: entry.heading, level: entry.level, bytes: Buffer.byteLength(body, "utf8") };
162
+ });
163
+ const bytes = Buffer.byteLength(document.content, "utf8");
164
+ // A topic whose whole body sits under a single H1 (the title) would produce a
165
+ // one-entry index — useless. Descend to the next level in that case, so the
166
+ // index always names the parts a caller can actually choose between.
167
+ const levels = [...new Set(headings.map((entry) => entry.level))].sort((left, right) => left - right);
168
+ const singleRoot = levels.length > 1 && headings.filter((entry) => entry.level === levels[0]).length === 1;
169
+ const indexLevel = singleRoot ? levels[1] : levels[0];
170
+ const visible = indexLevel === undefined ? sections : sections.filter((entry) => entry.level === indexLevel);
171
+ return { document, bytes, sections: visible.length > 0 ? visible : sections };
172
+ }
112
173
  //# sourceMappingURL=skills.js.map
package/dist/usage-log.js CHANGED
@@ -1,6 +1,7 @@
1
- import { appendFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, closeSync, statSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, closeSync, renameSync, statSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
- import { dirname, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
4
5
  let pending;
5
6
  /**
6
7
  * The project session marker: a working directory that opted into the usage log
@@ -10,6 +11,11 @@ let pending;
10
11
  export function projectSessionActive() {
11
12
  return logPath() !== undefined;
12
13
  }
14
+ /**
15
+ * The project-scoped log: only present when the working directory opted in with
16
+ * `.omc-env` (or `OMC_USAGE_LOG`). Session-scoped caches and idempotency memos
17
+ * key off THIS path so repo tests and the golden master stay stable.
18
+ */
13
19
  function logPath() {
14
20
  const explicit = process.env.OMC_USAGE_LOG;
15
21
  if (explicit)
@@ -17,6 +23,83 @@ function logPath() {
17
23
  const projectEnv = resolve(process.cwd(), ".omc-env");
18
24
  return existsSync(projectEnv) ? resolve(process.cwd(), ".omc", "usage.jsonl") : undefined;
19
25
  }
26
+ const USAGE_LOG_MAX_BYTES = 8 * 1024 * 1024;
27
+ /**
28
+ * Where invocation records are WRITTEN. Measurement must not silently stop just
29
+ * because a directory never ran `omc env use`: fall back to a per-directory file
30
+ * under the config root (`$OMC_CONFIG_HOME/usage/<cwd-hash>.jsonl`, default
31
+ * `~/.config/omc/usage/`). Tests isolate with OMC_CONFIG_HOME, so the fallback
32
+ * never leaks into the developer's real home during `npm test`.
33
+ *
34
+ * Memos still read `logPath()` only — a fallback log is append-only telemetry,
35
+ * never a source of "already did this" answers.
36
+ */
37
+ function recordLogPath() {
38
+ const explicit = process.env.OMC_USAGE_LOG;
39
+ if (explicit)
40
+ return { path: resolve(explicit), scope: "project" };
41
+ const project = logPath();
42
+ if (project)
43
+ return { path: project, scope: "project" };
44
+ if (process.env.OMC_USAGE_DISABLE === "1")
45
+ return undefined;
46
+ // The fallback writes into the user's config root. Keep it out of the test
47
+ // suite and out of scripted non-interactive runs unless explicitly asked:
48
+ // a measurement side effect must never be the reason a fixture directory
49
+ // suddenly has an extra file (or the developer's home grows during npm test).
50
+ if (process.env.NODE_TEST_CONTEXT !== undefined)
51
+ return undefined;
52
+ if (process.env.OMC_USAGE_USER_FALLBACK === "0")
53
+ return undefined;
54
+ const configRoot = process.env.OMC_CONFIG_HOME ? resolve(process.env.OMC_CONFIG_HOME) : join(homedir(), ".config", "omc");
55
+ const cwd = resolve(process.cwd());
56
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
57
+ return { path: join(configRoot, "usage", `${hash}.jsonl`), scope: "user" };
58
+ }
59
+ /**
60
+ * `.omc/` holds artifacts and this log; committing it leaks business data. The
61
+ * append is explicit-only (`omc setup` binding a project); `projectIgnoreState`
62
+ * is what the read side reports, so `omc usage` never edits the working tree.
63
+ */
64
+ function appendRecord(path, record) {
65
+ mkdirSync(dirname(path), { recursive: true });
66
+ try {
67
+ if (existsSync(path) && statSync(path).size > USAGE_LOG_MAX_BYTES)
68
+ renameSync(path, `${path}.1`);
69
+ }
70
+ catch {
71
+ // Rotation is best-effort: a failed rename must not drop the record.
72
+ }
73
+ appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
74
+ }
75
+ /** Append a `script-call` record: one line of `omc script` = one inner tool call. */
76
+ export function recordScriptCall(input) {
77
+ const target = recordLogPath();
78
+ if (!target)
79
+ return;
80
+ const envelope = input.result ?? {};
81
+ const error = envelope.error && typeof envelope.error === "object" ? envelope.error : undefined;
82
+ const resultEnvelope = envelope.result && typeof envelope.result === "object" ? envelope.result : undefined;
83
+ const blocker = typeof resultEnvelope?.blocker === "string" ? resultEnvelope.blocker : typeof envelope.blocker === "string" ? envelope.blocker : undefined;
84
+ const status = typeof envelope.status === "string" ? envelope.status : input.exitCode === 0 ? "ok" : "error";
85
+ appendRecord(target.path, {
86
+ kind: "script-call",
87
+ id: createHash("sha256").update(`${Date.now()}\u0000${input.tool}\u0000${input.index}`).digest("hex").slice(0, 16),
88
+ ...(input.parentId ? { parentId: input.parentId } : {}),
89
+ index: input.index,
90
+ tool: input.tool,
91
+ argsDigest: createHash("sha256").update(`${input.tool}\u0000${JSON.stringify(input.args ?? {})}`).digest("hex"),
92
+ argSummary: summarizeArgsObject(input.args ?? {}),
93
+ stdoutBytes: input.stdoutBytes,
94
+ durationMs: input.durationMs,
95
+ status,
96
+ exitCode: input.exitCode,
97
+ ...(typeof error?.code === "string" ? { errorCode: error.code } : {}),
98
+ ...(blocker ? { blocker } : {}),
99
+ startedAt: new Date().toISOString(),
100
+ ...(target.scope === "user" ? { scope: "user", cwd: resolve(process.cwd()) } : {}),
101
+ });
102
+ }
20
103
  /** The args digest of the in-flight invocation (session-scoped memo key). */
21
104
  export function currentUsageArgsDigest() {
22
105
  return pending?.argsDigest;
@@ -126,7 +209,7 @@ function commandOf(argv) {
126
209
  for (let index = 2; index < argv.length && values.length < 2; index += 1) {
127
210
  const token = argv[index];
128
211
  if (token.startsWith("--")) {
129
- if (["--out", "--max-output-bytes", "--args", "--args-file", "--env", "--environment", "--request-id", "--timeout-ms", "--wait", "--path"].includes(token))
212
+ if (["--out", "--max-output-bytes", "--args", "--args-file", "--env", "--environment", "--request-id", "--timeout-ms", "--wait", "--path", "--top"].includes(token))
130
213
  index += 1;
131
214
  continue;
132
215
  }
@@ -139,29 +222,41 @@ function facesOf(argv) {
139
222
  // (bounded index vs full catalogue) but `commandOf` drops every flag, so
140
223
  // without this they were indistinguishable in the log and byte attribution
141
224
  // was ambiguous (round 10).
142
- const faces = ["--full", "--compact", "--json", "--schema-only", "--param", "--example", "--section", "--sections", "--out", "--quiet"];
225
+ // Read projections count as faces too. r11 (2026-10-24) reported `--jq`/`--summary`/
226
+ // `--keys` usage as 0 because only the `describe`/`call` flags were listed:
227
+ // `omc artifact get <id> --jq <path>` showed up as a bare `artifact get` with
228
+ // empty argKeys, so the cost report could not tell a 200 B projection from a
229
+ // 44 KB pull — precisely the read path these flags exist to make cheap.
230
+ const faces = ["--full", "--compact", "--json", "--schema-only", "--param", "--example", "--section", "--sections", "--out", "--quiet", "--jq", "--summary", "--keys", "--path-only", "--extract", "--extract-keys", "--records"];
143
231
  return faces.filter((face) => argv.includes(face));
144
232
  }
145
233
  const safeArgumentKeys = new Set(["schemaCode", "propertyCode", "code", "workflowCode", "ruleCode", "sheetCode", "id", "dataRuleType", "propertyType", "published", "handler"]);
146
- function summarizeArgs(argv) {
234
+ function summarizeArgsObject(parsed) {
147
235
  const summary = { argKeys: [] };
236
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
237
+ const keys = Object.keys(parsed).sort();
238
+ summary.argKeys = keys;
239
+ // Only key names plus a fixed allowlist of business identifiers: the log is
240
+ // durable and shared, so free-form values (scripts, form data) stay out.
241
+ for (const key of keys)
242
+ if (safeArgumentKeys.has(key)) {
243
+ const value = parsed[key];
244
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
245
+ summary[key] = value;
246
+ }
247
+ }
248
+ return summary;
249
+ }
250
+ function summarizeArgs(argv) {
251
+ let summary = { argKeys: [] };
148
252
  const argsIndex = argv.indexOf("--args");
149
253
  if (argsIndex >= 0) {
150
254
  try {
151
255
  const parsed = JSON.parse(argv[argsIndex + 1] ?? "{}");
152
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
153
- const keys = Object.keys(parsed).sort();
154
- summary.argKeys = keys;
155
- for (const key of keys)
156
- if (safeArgumentKeys.has(key)) {
157
- const value = parsed[key];
158
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
159
- summary[key] = value;
160
- }
161
- }
256
+ summary = summarizeArgsObject(parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {});
162
257
  }
163
258
  catch {
164
- summary.argKeys = ["<invalid-json>"];
259
+ summary = { argKeys: ["<invalid-json>"] };
165
260
  }
166
261
  }
167
262
  const envIndex = argv.findIndex((token) => token === "--env" || token === "--environment");
@@ -175,8 +270,8 @@ function summarizeArgs(argv) {
175
270
  return summary;
176
271
  }
177
272
  export function beginUsage(argv) {
178
- const path = logPath();
179
- if (!path)
273
+ const target = recordLogPath();
274
+ if (!target)
180
275
  return;
181
276
  const raw = argv.slice(2).join("\u0000");
182
277
  const argsDigest = createHash("sha256").update(raw).digest("hex");
@@ -191,8 +286,13 @@ export function beginUsage(argv) {
191
286
  stdoutBytes: 0,
192
287
  fileBytes: 0,
193
288
  status: "no-envelope",
289
+ scope: target.scope,
194
290
  };
195
291
  }
292
+ /** The id of the in-flight invocation — `script-call` records point back at it. */
293
+ export function currentUsageId() {
294
+ return pending?.id;
295
+ }
196
296
  export function recordUsageOutput(value, stdoutBytes, fileBytes = 0) {
197
297
  if (!pending)
198
298
  return;
@@ -218,10 +318,9 @@ export function completeUsage(exitCode) {
218
318
  return;
219
319
  const current = pending;
220
320
  pending = undefined;
221
- const path = logPath();
222
- if (!path)
321
+ const target = recordLogPath();
322
+ if (!target)
223
323
  return;
224
- mkdirSync(dirname(path), { recursive: true });
225
324
  const record = {
226
325
  kind: "invocation",
227
326
  id: current.id,
@@ -238,8 +337,37 @@ export function completeUsage(exitCode) {
238
337
  ...(current.errorCode ? { errorCode: current.errorCode } : {}),
239
338
  ...(current.blocker ? { blocker: current.blocker } : {}),
240
339
  ...(current.requestId ? { requestId: current.requestId } : {}),
340
+ ...(target.scope === "user" ? { scope: "user", cwd: resolve(process.cwd()) } : {}),
241
341
  };
242
- appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
342
+ appendRecord(target.path, record);
343
+ }
344
+ /**
345
+ * Whether the project's `.omc/` directory is excluded from version control.
346
+ * Reported by `omc usage`; the actual append happens only in `env use`, because
347
+ * a read command must not mutate the working tree (r10 review: `omc usage` in a
348
+ * repo would otherwise edit .gitignore behind the caller's back).
349
+ */
350
+ export function projectIgnoreState(projectRoot = process.cwd()) {
351
+ if (!existsSync(join(projectRoot, ".git")))
352
+ return "no-git";
353
+ const ignoreFile = join(projectRoot, ".gitignore");
354
+ if (!existsSync(ignoreFile))
355
+ return "no-gitignore";
356
+ const text = readFileSync(ignoreFile, "utf8");
357
+ const present = text.split(/\r?\n/).some((line) => [".omc/", ".omc", "/.omc/", "/.omc"].includes(line.trim()));
358
+ return present ? "ignored" : "missing";
359
+ }
360
+ /** Add `.omc/` to an existing .gitignore (explicit setup path only). */
361
+ export function ensureProjectIgnore(projectRoot = process.cwd()) {
362
+ const state = projectIgnoreState(projectRoot);
363
+ if (state === "no-git" || state === "no-gitignore")
364
+ return state;
365
+ if (state === "ignored")
366
+ return "ignored";
367
+ const ignoreFile = join(projectRoot, ".gitignore");
368
+ const text = readFileSync(ignoreFile, "utf8");
369
+ appendFileSync(ignoreFile, `${text.endsWith("\n") || text.length === 0 ? "" : "\n"}.omc/\n`, "utf8");
370
+ return "appended";
243
371
  }
244
372
  function readUsageTail(path, maxBytes) {
245
373
  if (!existsSync(path))
@@ -280,67 +408,132 @@ function readUsage(path) {
280
408
  }
281
409
  });
282
410
  }
283
- export function usageReport(pathArg) {
284
- const path = pathArg ? resolve(pathArg) : logPath() ?? resolve(process.cwd(), ".omc", "usage.jsonl");
411
+ /** `call rule.save` → `rule.save`; anything else keeps its command name. */
412
+ function toolOf(command) {
413
+ return command.startsWith("call ") ? command.slice(5) : command;
414
+ }
415
+ export function usageReport(pathArg, options = {}) {
416
+ const fallback = options.resolveLikeWriter === true ? recordLogPath() : undefined;
417
+ const path = pathArg ? resolve(pathArg) : fallback?.path ?? logPath() ?? resolve(process.cwd(), ".omc", "usage.jsonl");
418
+ const scope = pathArg ? "explicit" : fallback?.scope ?? "project";
285
419
  const records = readUsage(path);
286
420
  const calls = records.filter((record) => record.kind === "invocation");
421
+ const scriptCalls = records.filter((record) => record.kind === "script-call");
422
+ const top = Math.max(1, Math.min(options.top ?? 10, 100));
287
423
  const commandCounts = new Map();
424
+ const toolCounts = new Map();
288
425
  let stdoutBytes = 0;
289
426
  let fileBytes = 0;
290
427
  let fullCalls = 0;
291
428
  for (const record of calls) {
292
429
  const command = String(record.command ?? "(unknown)");
430
+ const bytes = Number(record.stdoutBytes ?? 0);
293
431
  commandCounts.set(command, (commandCounts.get(command) ?? 0) + 1);
294
- stdoutBytes += Number(record.stdoutBytes ?? 0);
432
+ const tool = toolOf(command);
433
+ const entry = toolCounts.get(tool) ?? { calls: 0, bytes: 0, viaScript: 0 };
434
+ toolCounts.set(tool, { ...entry, calls: entry.calls + 1, bytes: entry.bytes + bytes });
435
+ stdoutBytes += bytes;
295
436
  fileBytes += Number(record.fileBytes ?? 0);
296
437
  if (Array.isArray(record.faces) && record.faces.includes("--full"))
297
438
  fullCalls += 1;
298
439
  }
299
- const repeated = [...commandCounts.entries()].filter(([, count]) => count > 1).map(([command, count]) => ({ command, count }));
440
+ // Inner `omc script` calls: the outer record's command is just "script <file>"
441
+ // and its bytes are 0 (script owns stdout), so without this roll-up every
442
+ // batch was invisible to cost accounting (round 10: 23 such calls, 0 B).
443
+ let scriptBytes = 0;
444
+ for (const record of scriptCalls) {
445
+ const bytes = Number(record.stdoutBytes ?? 0);
446
+ scriptBytes += bytes;
447
+ const tool = String(record.tool ?? "(unknown)");
448
+ const entry = toolCounts.get(tool) ?? { calls: 0, bytes: 0, viaScript: 0 };
449
+ toolCounts.set(tool, { ...entry, calls: entry.calls + 1, bytes: entry.bytes + bytes, viaScript: entry.viaScript + 1 });
450
+ }
451
+ const totalBytes = stdoutBytes + scriptBytes;
452
+ // Measurement, not vanity: how much of the session was the SAME call repeated.
300
453
  const exactCounts = new Map();
301
454
  const createdCodes = new Set();
302
455
  const referencedCodes = new Set();
303
- for (const record of calls) {
456
+ // Redundancy spans both faces: the same tool called twice inside ONE batch is
457
+ // exactly as wasteful as twice across two `omc call` invocations (round 10
458
+ // hid 23 batched calls behind `script -`).
459
+ const examined = [...calls, ...scriptCalls];
460
+ for (const record of examined) {
304
461
  const summary = record.argSummary && typeof record.argSummary === "object" ? record.argSummary : {};
305
- const command = String(record.command ?? "");
462
+ const command = record.kind === "script-call" ? String(record.tool ?? "") : String(record.command ?? "");
306
463
  const digest = `${command}:${String(record.argsDigest ?? "")}`;
307
- exactCounts.set(digest, (exactCounts.get(digest) ?? 0) + 1);
464
+ const entry = exactCounts.get(digest) ?? { count: 0, bytes: 0, command };
465
+ exactCounts.set(digest, { count: entry.count + 1, bytes: entry.bytes + Number(record.stdoutBytes ?? 0), command });
308
466
  if (command === "call model.create" && typeof summary.code === "string")
309
467
  createdCodes.add(summary.code);
310
468
  for (const key of ["schemaCode", "workflowCode"])
311
469
  if (typeof summary[key] === "string")
312
470
  referencedCodes.add(summary[key]);
313
471
  }
314
- const exactRepeated = [...exactCounts.entries()].filter(([, count]) => count > 1).map(([digest, count]) => ({ digest: digest.slice(-16), count }));
472
+ const repeated = [...commandCounts.entries()].filter(([, count]) => count > 1).map(([command, count]) => ({ command, count })).sort((left, right) => right.count - left.count);
473
+ // Wasted bytes: for a group of N identical calls, N-1 copies bought nothing.
474
+ let redundantCalls = 0;
475
+ let redundantBytes = 0;
476
+ const exactRepeated = [...exactCounts.entries()]
477
+ .filter(([, entry]) => entry.count > 1)
478
+ .map(([digest, entry]) => {
479
+ const wasted = Math.round((entry.bytes * (entry.count - 1)) / entry.count);
480
+ redundantCalls += entry.count - 1;
481
+ redundantBytes += wasted;
482
+ return { digest: digest.slice(-16), command: entry.command, count: entry.count, wastedBytes: wasted };
483
+ })
484
+ .sort((left, right) => right.wastedBytes - left.wastedBytes)
485
+ .slice(0, top);
315
486
  const unseenTargetReferences = [...referencedCodes].filter((code) => !createdCodes.has(code));
316
- // Round 4: attribute large reads. The complex benchmark showed `artifact get`
317
- // at 56% of stdout; without this the next session cannot see the whale.
318
487
  const artifactBytes = calls.filter((record) => String(record.command).startsWith("artifact get")).reduce((sum, record) => sum + Number(record.stdoutBytes ?? 0), 0);
319
488
  const largestReads = calls
320
489
  .filter((record) => Number(record.stdoutBytes ?? 0) >= 8192)
321
490
  .map((record) => ({ command: String(record.command ?? ""), bytes: Number(record.stdoutBytes ?? 0), summary: record.argSummary ?? {} }))
322
491
  .sort((left, right) => right.bytes - left.bytes)
323
- .slice(0, 8);
492
+ .slice(0, Math.min(top, 8));
493
+ const byTool = [...toolCounts.entries()]
494
+ .map(([tool, entry]) => ({
495
+ tool,
496
+ calls: entry.calls,
497
+ bytes: entry.bytes,
498
+ ...(entry.viaScript > 0 ? { viaScript: entry.viaScript } : {}),
499
+ share: totalBytes === 0 ? 0 : Number((entry.bytes / totalBytes).toFixed(3)),
500
+ }))
501
+ .sort((left, right) => right.bytes - left.bytes)
502
+ .slice(0, top);
324
503
  const violations = [
325
504
  ...(fullCalls > 3 ? [{ code: "batch-full-prefetch", message: `${fullCalls} calls used --full; prefer next/recipe/default describe` }] : []),
326
505
  ...(calls.some((record) => String(record.command).startsWith("commands") && !Array.isArray(record.faces)) ? [{ code: "broad-command-catalog", message: "full commands inventory was requested" }] : []),
327
506
  ...(artifactBytes > 32768 ? [{ code: "large-artifact-read", message: `artifact get pulled ${artifactBytes} B; prefer --summary/--keys/--jq or the projected workflow summary` }] : []),
507
+ ...(scope === "project" && projectIgnoreState(process.cwd()) === "missing" ? [{ code: "project-log-not-ignored", message: ".omc/ is not in .gitignore;重跑 `omc setup` 并选择绑定本项目(会自动追加),或手动加一行 .omc/,否则用量账本与产物可能被提交" }] : []),
328
508
  ];
329
509
  return {
330
510
  status: "ok",
331
511
  path,
512
+ scope,
513
+ // `path` is redacted (host layout stays private), so name the file itself:
514
+ // project logs are `<project>/.omc/usage.jsonl`, user logs live under
515
+ // `<config root>/usage/<hash>.jsonl`.
516
+ logFile: basename(path),
332
517
  calls: calls.length,
518
+ scriptCalls: scriptCalls.length,
333
519
  stdoutBytes,
520
+ scriptBytes,
521
+ totalBytes,
334
522
  fileBytes,
523
+ // Explicit estimate, never presented as a measurement: the CLI cannot see
524
+ // the agent's tokenizer, so bytes/4 is a floor for planning only.
525
+ estimatedTokens: Math.round(totalBytes / 4),
335
526
  fullCalls,
336
- repeated,
527
+ redundancy: { calls: redundantCalls, bytes: redundantBytes, share: Number((redundantCalls / Math.max(examined.length, 1)).toFixed(3)) },
528
+ repeated: repeated.slice(0, top),
337
529
  exactRepeated,
530
+ byTool,
338
531
  createdCodes: [...createdCodes],
339
532
  unseenTargetReferences,
340
533
  artifactBytes,
341
534
  largestReads,
342
535
  violations,
343
- records,
536
+ ...(options.records === true ? { records } : {}),
344
537
  };
345
538
  }
346
539
  //# sourceMappingURL=usage-log.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.4.79",
3
+ "version": "0.4.81",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,9 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@stalfh233/omc-fake-platform": "0.4.79",
53
+ "@stalfh233/omc-fake-platform": "0.4.81",
54
54
  "commander": "14.0.3",
55
- "omc-core": "0.4.79",
55
+ "omc-core": "0.4.81",
56
56
  "proper-lockfile": "4.1.2",
57
57
  "write-file-atomic": "6.0.0",
58
58
  "zod": "^3.23.8",
@@ -44,8 +44,12 @@ capability's coverage/tools/boundaries from `omc-capabilities`.
44
44
  Coverage status and boundaries for every capability (including 权限/子表/聚合/
45
45
  上线/内联/OCR) live in **`omc-capabilities`** — its 能力索引 table and
46
46
  [边界] section are the authority; read the relevant section there rather than a
47
- copy here. In particular, **跨模型/子表聚合、汇总、求和是配置能力**(表单「计算」
48
- FORMULA),不要当二开。
47
+ copy here. In particular, **跨模型/子表聚合、汇总、求和是配置能力**,不要当二开。
48
+ 落点是**字段计算规则**:`omc compute --schema <主表> --target <目标字段> --formula 'COUNTIF({<子表code>.<子列code>},"异常")'`
49
+ (等价 `datarule.create` `dataRuleType:2`,配方 `field-compute-subtable-sum` / `field-compute-child-column`)。
50
+ **不要**在规则图(`rule.save`)里用 `ruleValueType:"AGGREGATION"` —— 平台会存进去并启用,运行期聚合结果是集合,
51
+ 标量目标收到集合即 50000「服务异常」(r11 巡检沙盒 2026-10-24,1.1.25;OMC 现已写前 fail-closed)。
52
+ 计算由门户前端执行、API `data.load` 不回填,属平台既有行为:要断言结果请在测试数据里写预算值。
49
53
 
50
54
  Cue → capability is a starting point, never a substitute for 定面. A cue that
51
55
  could name two faces is resolved by the 定面表 in the parent skill.
@@ -24,7 +24,7 @@ be treated as a second source. Read this, then follow the topics it names.
24
24
  - `build`/`modify` → 按阶梯内联的**写工具 + 必填参数**执行。
25
25
  - `scout`(能不能/怎么写/是否支持)→ 先探边界(`omc-capability-scouting`),别直接写。若 `next` 返回 **`boundary`**(`match.kind:"boundary"`,如 `ocr-e-signature`/`release-prod`/`inline-coding`),说明需求**不在覆盖内**(`write:false`):如实报告、用 `omc capabilities` 核对,**不要**用相近能力伪造实现。
26
26
  阶梯步骤已内联工具必填参数(`route.ladder[].tools`/`readTools`)与命中 recipe 的 `describe <tool> --example <id>`;据此直接执行——**不要再逐能力 `commands --lane`**,也不要先扫 `commands --json`/全部 `contract`/全部 `describe --full`。
27
- 方法论按 `route.readSections` 逐节读:对每条 `{topic, section}` 执行 `omc help <topic> --section "<section>"`(单节 0.4–3.4KB)。**不要 `omc help <topic>` 读整篇**(5–14KB,读一次就留在后续每一轮窗口)。
27
+ 方法论按 `route.readSections` 逐节读:对每条 `{topic, section}` 执行 `omc help <topic> --section "<section>"`(单节 0.4–3.4KB)。`omc help <topic>` 默认只回节索引(约 1KB,含每节字节数);**不要 `--all` 读整篇**(5–14KB,读一次就留在后续每一轮窗口)。
28
28
  2. 写前只需读 `omc-config-draft-review`(人审门)与 `omc-five-piece-flow`(发现→草稿→权限→执行→回读);`omc-business-configuration` 按命中的能力分节读(见 `next` 的 `readSections`),不要整篇预取。
29
29
  3. 环境名以 `omc doctor` 的「环境=」或 `.omc-env` 为准;工具不确定先用默认 `describe`/`--example`,只有单个工具缺形状才用 `--full`;`describe` 带 `exampleHint`/`recipeHint` 时优先 `--example`。
30
30
  4. 只有工具不在 `next` 命中能力内、或要看 purpose/枚举时,才补一次 `commands --json --compact --lane <lane>`。非显然形状(规则图、流程模板、表单布局、RESTFUL)才单独 `describe --example`。