@stalfh233/omc-cli 0.4.88 → 0.4.91

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.
@@ -75,8 +75,83 @@ export function serializeEnvelope(value) {
75
75
  export function requestId() {
76
76
  return randomUUID();
77
77
  }
78
+ /**
79
+ * Derive a runnable next command from a refusal payload. Tools that already
80
+ * carry a structured recovery (an explicit `nextCommand`, an artifact `fetch`
81
+ * hint, `recovery[].command`, command-shaped `checks`, or a prepared
82
+ * `nextSubmit`) do not need their prose parsed; everything else stays null
83
+ * rather than inventing a command that does not apply.
84
+ */
85
+ export function nextCommandFor(result) {
86
+ const commandFrom = (text) => {
87
+ if (typeof text !== "string")
88
+ return undefined;
89
+ const trimmed = text.trim();
90
+ if (/^omc\s/.test(trimmed))
91
+ return trimmed;
92
+ // Recovery entries are often "① 按流程收窄(最可靠):omc call …" — the
93
+ // command is there, just after a human marker.
94
+ const index = trimmed.indexOf("omc ");
95
+ return index > 0 ? trimmed.slice(index).trim() : undefined;
96
+ };
97
+ const explicit = result.nextCommand;
98
+ if (typeof explicit === "string" && explicit.trim().length > 0)
99
+ return explicit.trim();
100
+ const fromFetch = commandFrom(result.fetch);
101
+ if (fromFetch !== undefined)
102
+ return fromFetch;
103
+ const recovery = result.recovery;
104
+ if (Array.isArray(recovery)) {
105
+ for (const entry of recovery) {
106
+ if (entry === null || typeof entry !== "object")
107
+ continue;
108
+ const command = commandFrom(entry.command);
109
+ if (command !== undefined)
110
+ return command;
111
+ }
112
+ }
113
+ for (const list of [result.checks, result.workaround]) {
114
+ if (!Array.isArray(list))
115
+ continue;
116
+ for (const entry of list) {
117
+ const command = commandFrom(entry);
118
+ if (command !== undefined)
119
+ return command;
120
+ }
121
+ }
122
+ // A submit that advanced the flow already prepared the NEXT approval args —
123
+ // expose the command instead of making the caller reassemble JSON by hand.
124
+ const nextSubmit = result.nextSubmit;
125
+ if (nextSubmit !== null && typeof nextSubmit === "object" && !Array.isArray(nextSubmit)) {
126
+ const record = nextSubmit;
127
+ if (typeof record.schemaCode === "string" && typeof record.workItemId === "string") {
128
+ const args = {
129
+ schemaCode: record.schemaCode,
130
+ bizObjectId: record.bizObjectId ?? null,
131
+ workflowInstanceId: record.workflowInstanceId ?? null,
132
+ workItemId: record.workItemId,
133
+ agree: true,
134
+ };
135
+ return `omc call data.submit --args '${JSON.stringify(args)}'`;
136
+ }
137
+ }
138
+ const nextWorkItems = result.nextWorkItems;
139
+ if (Array.isArray(nextWorkItems) && nextWorkItems.length > 0 && typeof result.schemaCode === "string") {
140
+ const item = nextWorkItems[0];
141
+ if (item !== null && typeof item === "object") {
142
+ const workItemId = item.workItemId;
143
+ if (typeof workItemId === "string")
144
+ return `omc call data.submit --args '${JSON.stringify({ schemaCode: result.schemaCode, workItemId, agree: true })}'`;
145
+ }
146
+ }
147
+ return undefined;
148
+ }
78
149
  export function classifyResult(result) {
79
150
  const status = String(result.status ?? "ok");
151
+ const withNext = (error) => {
152
+ const nextCommand = nextCommandFor(result);
153
+ return nextCommand === undefined ? error : { ...error, nextCommand };
154
+ };
80
155
  if (status === "ok")
81
156
  return { status, error: null, exitCode: CLI_EXIT_CODES.ok };
82
157
  if (status === "dry-run")
@@ -88,30 +163,45 @@ export function classifyResult(result) {
88
163
  const succeeded = Number(result.succeeded ?? 0);
89
164
  const requested = Number(result.requested ?? 0);
90
165
  const failed = Number(result.failed ?? 0);
166
+ // A `--preflight` batch shape-checks entries instead of executing them; the
167
+ // message must not claim "N 成功/失败" for calls that never ran.
168
+ // A step-chain command (model-purge) also answers `partial`, and the batch
169
+ // wording read "6/7 成功,0 失败" — meaningless for steps (r15).
170
+ const isBatch = typeof result.tool === "string";
171
+ const message = result.mode === "preflight"
172
+ ? `${failed}/${requested} 条未过写前校验(preflight:零 wire、零写入);逐条原因见 failures[]`
173
+ : isBatch
174
+ ? `${succeeded}/${requested} 成功,${failed} 失败;逐条原因见 failures[] / failedSteps[](--args-list 的 index 指向第几条)`
175
+ : `部分步骤失败:逐条原因见 steps[] 的 status/detail(已在失败处停下;幂等,修好后重跑同一条命令)`;
91
176
  return {
92
177
  status,
93
- error: { code: "partial-batch", message: `${succeeded}/${requested} 成功,${failed} 失败;逐条原因见 failures[] / failedSteps[](--args-list 的 index 指向第几条)`, retryable: false },
178
+ error: withNext({ code: "partial-batch", message, retryable: false }),
94
179
  exitCode: 1,
95
180
  };
96
181
  }
97
182
  if (status === "unknown-tool") {
98
183
  const candidates = Array.isArray(result.candidates) ? result.candidates : [];
99
184
  const suffix = candidates.length > 0 ? `;did you mean: ${candidates.join("、")}` : ";用 `omc commands --json --compact --lane <lane>` 或 `omc next` 定位工具";
100
- return { status: "error", error: { code: "invalid-input", message: `unknown tool ${String(result.tool ?? "")}${suffix}`, retryable: false }, exitCode: CLI_EXIT_CODES.invalidInput };
185
+ return { status: "error", error: withNext({ code: "invalid-input", message: `unknown tool ${String(result.tool ?? "")}${suffix}`, retryable: false }), exitCode: CLI_EXIT_CODES.invalidInput };
101
186
  }
102
187
  if (result.blocker === "tool-input-invalid")
103
- return { status: "error", error: { code: "invalid-input", message: String(result.message ?? "tool input is invalid"), retryable: false }, exitCode: CLI_EXIT_CODES.invalidInput };
188
+ return { status: "error", error: withNext({ code: "invalid-input", message: String(result.message ?? "tool input is invalid"), retryable: false }), exitCode: CLI_EXIT_CODES.invalidInput };
104
189
  if (status === "uncertain")
105
- return { status, error: { code: "uncertain", message: String(result.message ?? result.reason ?? "outcome is uncertain"), retryable: true }, exitCode: CLI_EXIT_CODES.uncertain };
190
+ return { status, error: withNext({ code: "uncertain", message: String(result.message ?? result.reason ?? "outcome is uncertain"), retryable: true }), exitCode: CLI_EXIT_CODES.uncertain };
106
191
  if (status === "refused") {
107
192
  if (result.blocker === "write-lock-held")
108
- return { status, error: { code: "policy-refused", message: String(result.message ?? "write lock held"), retryable: true }, exitCode: CLI_EXIT_CODES.policyRefused };
193
+ return { status, error: withNext({ code: "policy-refused", message: String(result.message ?? "write lock held"), retryable: true }), exitCode: CLI_EXIT_CODES.policyRefused };
109
194
  const auth = String(result.blocker ?? "").startsWith("auth") || String(result.blocker ?? "").includes("credential");
110
- return { status, error: { code: auth ? "auth-config" : "policy-refused", message: String(result.message ?? result.blocker ?? "operation refused"), retryable: false }, exitCode: auth ? CLI_EXIT_CODES.authConfig : CLI_EXIT_CODES.policyRefused };
195
+ // Every refusal gets a next step: a specific command when the tool knows
196
+ // one, otherwise the refusal router (`omc-refusals` explains which gate
197
+ // produced it — the documented path in omc-start-here).
198
+ const error = { code: auth ? "auth-config" : "policy-refused", message: String(result.message ?? result.blocker ?? "operation refused"), retryable: false };
199
+ const nextCommand = nextCommandFor(result) ?? (auth ? undefined : "omc help omc-refusals");
200
+ return { status, error: nextCommand === undefined ? error : { ...error, nextCommand }, exitCode: auth ? CLI_EXIT_CODES.authConfig : CLI_EXIT_CODES.policyRefused };
111
201
  }
112
202
  const message = String(result.message ?? "internal error");
113
203
  const auth = /credential|configuration|environment|login|authentication|auth/i.test(message);
114
- return { status: "error", error: { code: auth ? "auth-config" : "internal", message, retryable: false }, exitCode: auth ? CLI_EXIT_CODES.authConfig : CLI_EXIT_CODES.internal };
204
+ return { status: "error", error: withNext({ code: auth ? "auth-config" : "internal", message, retryable: false }), exitCode: auth ? CLI_EXIT_CODES.authConfig : CLI_EXIT_CODES.internal };
115
205
  }
116
206
  export function errorEnvelope(error, code, exitCode, request) {
117
207
  const message = error instanceof Error ? error.message : String(error);
package/dist/cli.js CHANGED
@@ -15,12 +15,12 @@ import { BATCH_MAX_ENTRIES, runBatchCall } from "./batch-call.js";
15
15
  import { cmdModelScaffold } from "./model-scaffold.js";
16
16
  import { cmdModelPurge } from "./model-purge.js";
17
17
  import { environmentWritable, requiresProductionConfirmation } from "./environment-policy.js";
18
- import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, requestId } from "./cli-contract.js";
18
+ import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, refusalText, requestId } from "./cli-contract.js";
19
19
  import { configureOutput, humanSummary, stripOutputFlags, writeEnvelope } from "./cli-output.js";
20
20
  import { beginUsage, completeUsage, ensureProjectIgnore, recordUsageOutput, usageReport } from "./usage-log.js";
21
21
  import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
22
22
  import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath, pruneArtifacts } from "./artifacts.js";
23
- import { projectJsonPath, projectResultExtract } from "./path-projection.js";
23
+ import { projectJsonPath, projectJsonPathFanout, projectResultExtract } from "./path-projection.js";
24
24
  import { basename, join, resolve } from "node:path";
25
25
  import { buildToolRegistry } from "./tools.js";
26
26
  import { acquireWriteLock } from "./write-lock.js";
@@ -319,6 +319,52 @@ async function cmdBatchCall(store, toolName, rawList, options) {
319
319
  }));
320
320
  return CLI_EXIT_CODES.invalidInput;
321
321
  }
322
+ // Whole-batch shape precheck (issue 2026-10-25 §6): `--args-list --dry-run`
323
+ // runs every entry through the full tool (reads included) to report N
324
+ // previews. When the caller only wants "will this batch be accepted", the
325
+ // per-tool semantic gate is the thing that refuses mis-shapes, and it is
326
+ // pure — no wire, no writes. Run it for every entry and report ALL failures
327
+ // in one envelope instead of N round-trips.
328
+ if (options.preflight === true) {
329
+ const registry = buildToolRegistry();
330
+ const tool = registry.find((definition) => definition.name === toolName);
331
+ if (!tool) {
332
+ writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: `unknown tool ${toolName};用 omc next 或 omc commands --json --compact --lane <lane> 定位`, retryable: false }));
333
+ return CLI_EXIT_CODES.invalidInput;
334
+ }
335
+ const failures = [];
336
+ entries.forEach((entry, index) => {
337
+ try {
338
+ const parsedEntry = parseToolInput(tool, {
339
+ ...entry,
340
+ ...(options.environment !== undefined && entry.environment === undefined ? { environment: options.environment } : {}),
341
+ });
342
+ tool.writePreflight?.(parsedEntry);
343
+ }
344
+ catch (error) {
345
+ // ToolRefusalError carries the real payload (blocker/citation/validation);
346
+ // rebuild it only when the failure is a plain Error.
347
+ const carried = error.payload;
348
+ const payload = error instanceof ToolInputError ? error.payload : carried ?? { status: "refused", blocker: "tool-input-invalid", message: error instanceof Error ? error.message : String(error) };
349
+ failures.push({ index, blocker: payload.blocker ?? null, message: refusalText(payload), ...(Array.isArray(payload.validation) ? { validation: payload.validation } : {}), ...(payload.citation !== undefined ? { citation: payload.citation } : {}) });
350
+ }
351
+ });
352
+ const result = {
353
+ status: failures.length > 0 ? "partial" : "ok",
354
+ mode: "preflight",
355
+ tool: toolName,
356
+ requested: entries.length,
357
+ shapeChecked: entries.length,
358
+ failed: failures.length,
359
+ writes: 0,
360
+ // On failure the caller has to change the JSON first, so there is no
361
+ // runnable command yet; on success, hand over the exact execution command.
362
+ ...(failures.length > 0 ? { failures } : { nextCommand: `omc call ${toolName} --args-list ${JSON.stringify(JSON.stringify(entries))}` }),
363
+ };
364
+ const classified = classifyResult(result);
365
+ writeEnvelope(envelope(classified.status, result, request, classified.error));
366
+ return failures.length > 0 ? 1 : 0;
367
+ }
322
368
  const batched = await runBatchCall({
323
369
  tool: toolName,
324
370
  argsList: entries,
@@ -352,7 +398,7 @@ async function cmdCall(store, args) {
352
398
  const positional = args.filter((token, index) => !token.startsWith("--") && (index === 0 || !args[index - 1]?.startsWith("--env") && !args[index - 1]?.startsWith("--environment") && !args[index - 1]?.startsWith("--args")));
353
399
  const toolName = positional[0];
354
400
  if (!toolName || toolName === "help" || args.includes("--help")) {
355
- console.error("用法:omc call <tool> [--env <环境>] [--args '<json>' | --args - (stdin) | --args-file <配置根内路径>] [--extract <path>]... [--extract-keys]");
401
+ console.error("用法:omc call <tool> [--env <环境>] [--args '<json>' | --args - (stdin) | --args-file <配置根内路径>] [--extract <path>]... [--jq [path]] [--extract-keys] [--args-list <json数组> [--preflight]]");
356
402
  console.error("写入:由环境 writable 决定(只读环境拒写);生产环境的写必须在 args 里带 confirmProduction:true。");
357
403
  return emit(toolName && toolName !== "help" ? "error" : "ok", {}, toolName && toolName !== "help" ? CLI_EXIT_CODES.invalidInput : 0);
358
404
  }
@@ -361,14 +407,19 @@ async function cmdCall(store, args) {
361
407
  // Batch mode: the SAME tool N times, one process (r12: 36 field.create calls).
362
408
  const argsList = flag("--args-list");
363
409
  if (argsList !== undefined)
364
- return cmdBatchCall(store, toolName, argsList, { environment, requestValue, args });
410
+ return cmdBatchCall(store, toolName, argsList, { environment, requestValue, args, preflight: args.includes("--preflight") });
365
411
  // 1.2 of issue 2026-10-23: read faces answer {detailArtifactId, sha256} and the
366
412
  // caller then needs a SECOND call to see one value (16 two-step chains in one
367
413
  // session; 15 more were pure --keys probes). `--extract <path>` (repeatable)
368
414
  // resolves the path inside the artifact of THIS result and returns only the
369
415
  // value; `--extract-keys` lists the artifact's top-level keys with sizes.
370
- const extractPaths = argvValues(args, "--extract");
371
- const extractKeys = args.includes("--extract-keys");
416
+ //
417
+ // issue 2026-10-25 §8: `artifact get` had `--jq` while `omc call` did not, so
418
+ // every list read that only wanted one column paid a second artifact round.
419
+ // `--jq` is the same path projection plus `[]` fan-out; a bare `--jq` is the
420
+ // key inventory (the same muscle memory as `artifact get <id> --jq`).
421
+ const extractPaths = [...argvValues(args, "--extract"), ...argvValues(args, "--jq")];
422
+ const extractKeys = args.includes("--extract-keys") || args.some((token, index) => token === "--jq" && (args[index + 1] === undefined || args[index + 1].startsWith("--")));
372
423
  const dryRun = args.includes("--dry-run");
373
424
  const waitValue = flag("--wait");
374
425
  let waitMs = 0;
@@ -547,6 +598,23 @@ function parseDurationMs(text) {
547
598
  * template re-read three times is 81 KB of the session.
548
599
  */
549
600
  const ARTIFACT_INLINE_CEILING_BYTES = 8192;
601
+ /**
602
+ * `artifact get --jq`: one path walk, with `[]` fan-out (same syntax as
603
+ * `omc call --jq`). Returns the envelope fields so both the `--summary` and the
604
+ * plain branch stay identical.
605
+ */
606
+ function projectArtifactPath(value, path) {
607
+ if (!path.includes("[]")) {
608
+ const projected = projectJsonPath(value, path);
609
+ return projected.missed
610
+ ? { missed: true, ...(projected.at !== undefined ? { at: projected.at } : {}), ...(projected.available !== undefined ? { available: projected.available } : {}) }
611
+ : { missed: false, content: projected.value ?? null };
612
+ }
613
+ const fanout = projectJsonPathFanout(value, path);
614
+ return fanout.missed
615
+ ? { missed: true, ...(fanout.at !== undefined ? { at: fanout.at } : {}), ...(fanout.available !== undefined ? { available: fanout.available } : {}) }
616
+ : { missed: false, content: fanout.values, mapped: fanout.values.length, ...(fanout.missedEntries > 0 ? { missedEntries: fanout.missedEntries } : {}) };
617
+ }
550
618
  function formatBytes(bytes) {
551
619
  if (bytes < 1024)
552
620
  return `${bytes}B`;
@@ -579,10 +647,10 @@ async function cmdArtifactGet(id, transport, options = {}) {
579
647
  // this the caller had to spend one round on the shape and another on the
580
648
  // path (or pull the whole body); r5 hit that on 67 KB workflow templates.
581
649
  if (options.jq !== undefined) {
582
- const projected = projectJsonPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
650
+ const projected = projectArtifactPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
583
651
  return emitTransportResult(projected.missed
584
652
  ? { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, projection: { path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available }, fetch: artifactFetchHint(id) }
585
- : { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, projection: { path: options.jq, content: projected.value ?? null } }, transport);
653
+ : { status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, projection: { path: options.jq, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}) } }, transport);
586
654
  }
587
655
  return emitTransportResult({ status: "ok", artifactId: id, sha256: artifact.sha256, bytes: artifact.content.length, keys, fetch: artifactFetchHint(id) }, transport);
588
656
  }
@@ -611,10 +679,10 @@ async function cmdArtifactGet(id, transport, options = {}) {
611
679
  return emitTransportResult(result, transport);
612
680
  }
613
681
  if (options.jq !== undefined) {
614
- const projected = projectJsonPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
682
+ const projected = projectArtifactPath(artifact.content.toString("utf8") === "" ? undefined : content, options.jq);
615
683
  return emitTransportResult(projected.missed
616
- ? { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, hint: `路径未命中段 "${projected.at}"——available 是该层可用的键/范围;方括号下标(a[0])与点式(a.0)等价` }
617
- : { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: projected.value ?? null }, transport);
684
+ ? { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: null, pathMiss: true, missedAt: projected.at, available: projected.available, hint: `路径未命中段 "${projected.at}"——available 是该层可用的键/范围;方括号下标(a[0])与点式(a.0)等价;[] 映射数组(items[].fields.x)` }
685
+ : { status: "ok", artifactId: id, sha256: artifact.sha256, path: options.jq, content: projected.content ?? null, ...(projected.mapped !== undefined ? { mapped: projected.mapped } : {}), ...(projected.missedEntries !== undefined ? { missedEntries: projected.missedEntries } : {}) }, transport);
618
686
  }
619
687
  // r6-onboarding 2026-10-21: 47 artifact fetches, 0 used a projection; the 10
620
688
  // large ones (up to 55 KB) pulled whole workflow templates just to read one
@@ -1090,7 +1158,7 @@ function onboardingHelp() {
1090
1158
  "首次使用、或不确定从哪开始:omc help omc-start-here(随包权威 topic 路线);被拒或状态异常:omc help omc-refusals。",
1091
1159
  "方法论按需读:omc help <topic> 默认只回节索引(每节字节数);用 --section \"<标题>\" 取正文(可重复),确需整篇才 --all。",
1092
1160
  "目标→工具一步定位:omc next \"<你要做什么>\"(离线、只读,给能力/说明/首批命令;不确定会明说并回退到 omc-requirement-analysis)。",
1093
- "大结果取值一步到位:读面回 detailArtifactId 时,用 omc call <tool> … --extract <path>(可重复)或 --extract-keys,省掉再跑一次 omc artifact get;artifact get 正文超 8KB 默认也只回形状(--jq/--keys 定向取,--full 才整包)。",
1161
+ "大结果取值一步到位:读面回 detailArtifactId 时,用 omc call <tool> … --jq <path>(可重复;`[]` 映射数组,如 items[].fields.planNo)或 --extract/--extract-keys,省掉再跑一次 omc artifact get;artifact get 正文超 8KB 默认也只回形状(--jq/--keys 定向取,--full 才整包)。批量形状预检用 --args-list … --preflight(零 wire、零写入,一次报全)。",
1094
1162
  "工具发现:omc commands --json(含 capability id/lane/recipes;加 --compact 只回工具行、无目录散文)· omc capabilities(能力 id/lane/aliases/tools/依赖顺序)· omc capability <id> --executable(单能力完整执行包)。",
1095
1163
  "单工具/契约:omc describe <tool>(schema+recipe+能力路由)· omc contract <lane>(wire 契约+能力路由);环境就绪:omc call discover_environment --env <env> --args '{}'。",
1096
1164
  "写入两道门:对话层草稿确认(omc help omc-config-draft-review)+ 环境 writable 开关;生产环境每笔写需在 args 里带 confirmProduction:true。",
@@ -1214,8 +1282,8 @@ async function main() {
1214
1282
  commandExitCode = CLI_EXIT_CODES.ok;
1215
1283
  });
1216
1284
  command.command("doctor").option("--environment <environment>").option("--env <environment>").option("--service <serviceCode>", "可选:体检单个业务集成服务的鉴权参数形状(掩码/占位/全 0 检出,WARN 非致命)").action(async (options) => { commandExitCode = await cmdDoctor(new ConfigStore(defaultPaths()), options.environment ?? options.env, options.service); });
1217
- command.command("call <tool>").option("--env <environment>").option("--environment <environment>").option("--args <json>").option("--args-file <path>").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象);逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
1218
- const rest = [tool, ...(options.env ? ["--env", options.env] : []), ...(options.environment ? ["--environment", options.environment] : []), ...(options.args !== undefined ? ["--args", options.args] : []), ...(options.argsFile !== undefined ? ["--args-file", options.argsFile] : []), ...(options.argsList !== undefined ? ["--args-list", options.argsList] : []), ...(Array.isArray(options.extract) ? options.extract.flatMap((path) => ["--extract", path]) : []), ...(options.extractKeys ? ["--extract-keys"] : []), ...(options.maxOutputBytes !== undefined ? ["--max-output-bytes", options.maxOutputBytes] : []), ...(options.requestId ? ["--request-id", options.requestId] : []), ...(options.timeoutMs !== undefined ? ["--timeout-ms", options.timeoutMs] : []), ...(options.wait !== undefined ? ["--wait", options.wait] : []), ...(options.dryRun ? ["--dry-run"] : [])];
1285
+ command.command("call <tool>").option("--env <environment>").option("--environment <environment>").option("--args <json>").option("--args-file <path>").option("--args-list <json-array>", '同一工具跑 N 次(JSON 数组,每条一个 args 对象);逐条执行、默认不中断,最后回 succeeded/failed 汇总。适合批量建字段等').option("--preflight", "与 --args-list 一起用:只跑参数校验 + 写前语义闸(零 wire、零写入),一次报全所有形状问题").addOption(new Option("--extract <path>", "在本次结果的 artifact 内取一个路径的值(可重复;省掉一次 artifact get)").argParser((value, previous = []) => [...previous, value]).default([])).addOption(new Option("--jq [path]", "同 --extract,并支持 [] 映射(如 items[].fields.planNo);裸 --jq 列出结果的顶层键").argParser((value, previous = []) => [...previous, value]).default([])).option("--extract-keys", "列出本次结果 artifact 的顶层键与体积(省掉一次 artifact get --keys)").option("--max-output-bytes <bytes>").option("--request-id <requestId>").option("--timeout-ms <milliseconds>").option("--wait <milliseconds>").option("--dry-run").addHelpText("after", `\n${onboardingHelp()}\n`).action(async (tool, options) => {
1286
+ const rest = [tool, ...(options.env ? ["--env", options.env] : []), ...(options.environment ? ["--environment", options.environment] : []), ...(options.args !== undefined ? ["--args", options.args] : []), ...(options.argsFile !== undefined ? ["--args-file", options.argsFile] : []), ...(options.argsList !== undefined ? ["--args-list", options.argsList] : []), ...(options.preflight ? ["--preflight"] : []), ...(Array.isArray(options.extract) ? options.extract.flatMap((path) => ["--extract", path]) : []), ...(Array.isArray(options.jq) ? options.jq.flatMap((path) => path === true ? ["--jq"] : ["--jq", path]) : []), ...(options.extractKeys ? ["--extract-keys"] : []), ...(options.maxOutputBytes !== undefined ? ["--max-output-bytes", options.maxOutputBytes] : []), ...(options.requestId ? ["--request-id", options.requestId] : []), ...(options.timeoutMs !== undefined ? ["--timeout-ms", options.timeoutMs] : []), ...(options.wait !== undefined ? ["--wait", options.wait] : []), ...(options.dryRun ? ["--dry-run"] : [])];
1219
1287
  commandExitCode = await cmdCall(new ConfigStore(defaultPaths()), rest);
1220
1288
  });
1221
1289
  command.command("compute").option("--schema <schemaCode>").option("--schema-code <schemaCode>").option("--target <propertyCode>").option("--property <propertyCode>").option("--formula <formula>").option("--where <condition>", "条件聚合:\"<子列code> = <值>\",与 --formula \"{<子表code>.<子列code>}\" 配合编译为 COUNTIF/SUMIF").option("--aggregate <count|sum>", "条件聚合用哪种:count(默认,→COUNTIF)/ sum(→SUMIF)").option("--allow-unfiltered", "显式接受「对整个子表列无条件聚合」;不给则拒绝(会把不符合条件的行也算进去)").option("--name <name>").option("--env <environment>").option("--environment <environment>").option("--apply").addHelpText("after", "\n字段计算规则拼装器:公式 {字段} 引用 → datarule type2 完整形状(editorText/editorMark 自动生成)。默认 dry-run,--apply 写入。条件聚合用 --formula \"{子表.子列}\" --where \"子列 = 值\"。复杂场景(条件分支/跨模型)走 omc next 的 recipe 路径。\n").action(async (options) => {
@@ -113,6 +113,29 @@ function capabilityScores(registry, goal) {
113
113
  }
114
114
  return scores;
115
115
  }
116
+ /**
117
+ * Default ladder examples (r15). A ladder step with no goal-matching recipe only
118
+ * got `omc commands --lane`, so "创建业务规则" handed the caller nothing runnable
119
+ * even though `rule.save` ships examples. These are the lane's canonical shapes,
120
+ * used ONLY when the goal does not match a specific recipe.
121
+ */
122
+ const DEFAULT_LADDER_RECIPES = {
123
+ businessrule: { tool: "rule.save", recipe: "business-rule-branch-conditional" },
124
+ datarule: { tool: "datarule.create", recipe: "field-compute-rule" },
125
+ };
126
+ function defaultRecipeFor(registry, capabilityId, lane) {
127
+ const wanted = DEFAULT_LADDER_RECIPES[capabilityId];
128
+ if (!wanted)
129
+ return undefined;
130
+ for (const tool of registry) {
131
+ if (tool.name !== wanted.tool || tool.lane !== lane)
132
+ continue;
133
+ const recipe = (tool.executionMetadata?.recipes ?? []).find((entry) => entry.id === wanted.recipe);
134
+ if (recipe)
135
+ return { tool: tool.name, recipe: recipe.id, title: recipe.title };
136
+ }
137
+ return undefined;
138
+ }
116
139
  function bestRecipe(registry, capabilityId, lane, goal, minScore = 3) {
117
140
  let best;
118
141
  for (const tool of registry) {
@@ -238,17 +261,27 @@ function sectionPointersFor(ids) {
238
261
  return pointers;
239
262
  }
240
263
  /**
241
- * Ready-to-run methodology reads, one per topic, that fetch ALL of that topic's
242
- * relevant sections in a single call. Round 15: bench60-62 fetched each
243
- * `readSections` entry separately (~18 round-trips); `--section` is repeatable
244
- * and comma-separated, so grouping by topic collapses them to ~3 without adding
245
- * any bytes (the outputs land in context either way — the saving is turns).
264
+ * Ready-to-run methodology reads.
265
+ *
266
+ * Round 15 (bench60-62) collapsed ~18 per-section reads into one per topic to
267
+ * save turns. r10 F7 found the other edge: following that hint fetched
268
+ * EVERYTHING at once (~15 KB) when most tasks need only the first section.
269
+ * So the primary command per topic fetches the FIRST required section, and the
270
+ * remaining sections are offered as separate incremental commands (each keeps
271
+ * its byte count in `readSections`) — cheap by default, still one command away.
246
272
  */
247
- function batchReadCommands(pointers) {
273
+ function splitReadCommands(pointers) {
248
274
  const byTopic = new Map();
249
275
  for (const pointer of pointers)
250
276
  byTopic.set(pointer.topic, [...(byTopic.get(pointer.topic) ?? []), pointer.section]);
251
- return [...byTopic.entries()].map(([topic, sections]) => `omc help ${topic} ${sections.map((section) => `--section "${section}"`).join(" ")}`);
277
+ const readCommands = [];
278
+ const readMore = [];
279
+ for (const [topic, sections] of byTopic.entries()) {
280
+ readCommands.push(`omc help ${topic} --section "${sections[0]}"`);
281
+ for (const section of sections.slice(1))
282
+ readMore.push(`omc help ${topic} --section "${section}"`);
283
+ }
284
+ return { readCommands, readMore };
252
285
  }
253
286
  /**
254
287
  * The route's methodology-read surface: pointers (+ per-section bytes) +
@@ -279,14 +312,15 @@ function routeReads(readSections) {
279
312
  }
280
313
  }
281
314
  }
282
- return { readSections: budgeted, readCommands: batchReadCommands(readSections), readHint: READ_HINT, ...(firstSection !== undefined ? { firstSection } : {}) };
315
+ const commands = splitReadCommands(readSections);
316
+ return { readSections: budgeted, readCommands: commands.readCommands, ...(commands.readMore.length > 0 ? { readMore: commands.readMore } : {}), readHint: READ_HINT, ...(firstSection !== undefined ? { firstSection } : {}) };
283
317
  }
284
318
  /** UTF-8 byte length of one methodology section (best-effort; 0 when unreadable). */
285
319
  function sectionBytes(pointer) {
286
320
  const resolved = readSkillSection(pointer.topic, pointer.section);
287
321
  return resolved ? Buffer.byteLength(resolved.section, "utf8") : 0;
288
322
  }
289
- const READ_HINT = '按 readCommands 一次读完(每条命令用可重复的 `--section` 取该 topic 的所有相关节,等价于逐节 `omc help <topic> --section "<section>"` 但只花一次往返);单节字节数见 readSectionBudget,按预算自选。`omc help <topic>` 默认只回节索引(每节带字节数,约 1KB),不要再用 `--all` 读整篇(5–14KB,读一次就留在后续每一轮)。';
323
+ const READ_HINT = '先跑 readCommands(每个 topic 只取第一节,形状 `omc help <topic> --section "<section>"`,字节数见 readSections);不够再按需补 readMore,不要一次全读。`omc help <topic>` 默认只回节索引(每节带字节数,约 1KB),不要再用 `--all` 读整篇(5–14KB,读一次就留在后续每一轮)。';
290
324
  function orderedCapabilities(ids, index) {
291
325
  const selected = new Set(ids);
292
326
  const nodes = [...index.nodeById.values()];
@@ -322,6 +356,18 @@ function orderedCapabilities(ids, index) {
322
356
  */
323
357
  const WHOLE_PACKAGE_CAPABILITIES = ["model", "field", "form", "listview", "datarule", "businessrule", "workflow", "onlinejs"];
324
358
  const COMPOSER_ROUTES = Object.freeze([
359
+ {
360
+ id: "model-purge",
361
+ command: "omc model-purge --code <模型code> --purge-instances --apply",
362
+ usage: "omc model-purge --help",
363
+ summary: "清理一个模型:先删流程实例,再 model.delete 级联带走流程模板/视图/字段/表单/规则;默认 dry-run,不可删的停滞实例会跳过并在结尾列出",
364
+ // r15: "清理本轮创建的模型和流程实例" matched approval-flow (流程 + 创建)
365
+ // and would have CREATED a workflow instead of removing one. Cleanup intents
366
+ // are their own composer, and this one comes first.
367
+ groups: [["清理", "删除", "移除", "回收", "清除", "purge", "teardown", "clean", "remove"], ["模型", "实例", "数据", "model", "instance", "data"]],
368
+ avoid: ["字段", "角色", "部门", "视图", "按钮", "规则图"],
369
+ topics: ["omc-write-hazards"],
370
+ },
325
371
  {
326
372
  id: "model-scaffold",
327
373
  command: "omc model-scaffold --code <模型code> --name <模型名> --fields '<JSON数组>' --apply",
@@ -339,7 +385,7 @@ const COMPOSER_ROUTES = Object.freeze([
339
385
  summary: "线性审批流一条命令:建流程 → 模板+字段只读权限 → 发布(多级用重复 --steps)",
340
386
  groups: [["审批", "流程"], ["建", "创建", "新建", "配置", "加", "搭"]],
341
387
  // Branching / countersign shapes are NOT this composer's business.
342
- avoid: ["条件分支", "分支", "会签", "并行", "或签", "网关", "子流程", "动态审批人"],
388
+ avoid: ["条件分支", "分支", "会签", "并行", "或签", "网关", "子流程", "动态审批人", "清理", "删除", "移除", "purge", "teardown"],
343
389
  topics: ["omc-five-piece-flow"],
344
390
  },
345
391
  {
@@ -531,7 +577,7 @@ export function goalRoute(registry, goal) {
531
577
  if (readish) {
532
578
  return { capability: id, lane: capability.lane, readTools: readToolsInLane(registry, capability.lane), firstCommand: `omc capability ${id}` };
533
579
  }
534
- const recipe = bestRecipe(registry, id, capability.lane, goal);
580
+ const recipe = bestRecipe(registry, id, capability.lane, goal) ?? defaultRecipeFor(registry, id, capability.lane);
535
581
  return {
536
582
  capability: id,
537
583
  lane: capability.lane,
@@ -45,6 +45,54 @@ function fieldRollbackSpec(toolName) {
45
45
  function parseFieldOptionsForReadback(options) {
46
46
  return decodeJsonColumn(options, JSON_COLUMN_PASSTHROUGH);
47
47
  }
48
+ /**
49
+ * CHILD_TABLE(8) columns as a compact list (issue 2026-10-25 §7).
50
+ *
51
+ * The stored `subSchema` is the full designer payload — system columns included,
52
+ * ~37.6 KB for a 10-column child table when asked for one field by code. A caller
53
+ * almost always wants the column codes (to build formData / verify the attach);
54
+ * the raw payload stays available behind `full:true`. Accepts both the object and
55
+ * the JSON-string wire form.
56
+ */
57
+ function childColumnsOf(subSchema) {
58
+ let parsed = subSchema;
59
+ if (typeof subSchema === "string") {
60
+ try {
61
+ parsed = JSON.parse(subSchema);
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ if (parsed === null || typeof parsed !== "object")
68
+ return [];
69
+ const properties = parsed.properties;
70
+ if (!Array.isArray(properties))
71
+ return [];
72
+ return properties
73
+ .filter((child) => child !== null && typeof child === "object")
74
+ .map((child) => {
75
+ const rawType = child.propertyType ?? child.type;
76
+ const type = rawType === undefined || rawType === null || Number.isNaN(Number(rawType)) ? null : Number(rawType);
77
+ const typeLabel = type === null ? undefined : fieldTypeLabel(type);
78
+ const code = String(child.code ?? child.propertyCode ?? "");
79
+ return {
80
+ code,
81
+ name: String(child.name ?? child.code ?? child.propertyCode ?? ""),
82
+ type,
83
+ ...(typeLabel !== undefined ? { typeName: typeLabel } : {}),
84
+ ...(typeof child.relativeCode === "string" && child.relativeCode ? { relativeCode: child.relativeCode } : {}),
85
+ // r15 feedback: the projection mixed the platform's system columns with
86
+ // the authored ones, so "which child columns did I declare?" still needed
87
+ // a filter. Keep every column but mark the system ones.
88
+ // `parentId` / `sortKey` are injected by the platform into every child
89
+ // schema (they are the child row's link + order), so they are system
90
+ // columns here even though a MAIN model never carries them.
91
+ ...(SYSTEM_FIELD_CODES.has(code) || code === "parentId" || code === "sortKey" ? { system: true } : {}),
92
+ };
93
+ })
94
+ .filter((child) => child.code.length > 0);
95
+ }
48
96
  /**
49
97
  * `name_i18n` is a JSON locale map. When every locale value equals the display
50
98
  * `name` there is no translation to convey, so echoing the map on every field of
@@ -117,7 +165,21 @@ export function registerFieldLane(tools) {
117
165
  ...(redundantNameI18n(row.name_i18n, row.name) ? {} : typeof row.name_i18n === "string" ? { name_i18n: row.name_i18n } : {}),
118
166
  type: row.type ?? row.propertyType,
119
167
  ...(typeLabel !== undefined ? { typeName: typeLabel } : {}),
120
- ...(row.subSchema !== undefined && row.subSchema !== null ? { subSchema: row.subSchema } : {}),
168
+ // A CHILD_TABLE row's `subSchema` is the whole designer payload; the
169
+ // default face carries the column list + count and keeps the raw
170
+ // payload behind `full:true` (issue 2026-10-25 §7: one field read was
171
+ // 37.6 KB for a 5-code answer).
172
+ ...(row.subSchema !== undefined && row.subSchema !== null
173
+ ? args.full === true
174
+ ? { subSchema: row.subSchema }
175
+ : (() => {
176
+ const subColumns = childColumnsOf(row.subSchema);
177
+ const businessColumns = subColumns.filter((column) => column.system !== true).length;
178
+ return subColumns.length > 0
179
+ ? { subColumnCount: subColumns.length, businessSubColumnCount: businessColumns, subColumns, subSchemaHint: "子表列已投影为 code/name/type(system:true 是平台注入的系统列);要平台原始 subSchema 加 full:true" }
180
+ : { subSchema: row.subSchema };
181
+ })()
182
+ : {}),
121
183
  ...(row.relativeCode ? { relativeCode: row.relativeCode } : {}),
122
184
  ...(typeof row.relativePropertyCode === "string" && row.relativePropertyCode ? { relativePropertyCode: row.relativePropertyCode } : {}),
123
185
  ...(options !== undefined ? { options } : {}),
@@ -150,11 +212,20 @@ export function registerFieldLane(tools) {
150
212
  const value = options[key];
151
213
  return key === "readonlyFormula" ? value === true : typeof value === "string" && value.trim() !== "";
152
214
  });
215
+ // Inventory reads could not see child columns at all (r13 §7): the
216
+ // only face that carried them was the 37.6 KB subSchema. Codes alone
217
+ // are ~12 B each and answer "did the child columns land?".
218
+ const subColumns = field.subColumns;
219
+ const businessCodes = Array.isArray(subColumns) ? subColumns.filter((column) => column.system !== true).map((column) => column.code) : undefined;
153
220
  return {
154
221
  code: field.code,
155
222
  type: field.type,
156
223
  ...(field.typeName !== undefined ? { typeName: field.typeName } : {}),
157
224
  ...(field.name !== undefined ? { name: field.name } : {}),
225
+ // Inventory answers "which columns did I author?" — system
226
+ // columns are reported as a count, not mixed into the list.
227
+ ...(businessCodes !== undefined ? { subColumnCodes: businessCodes } : {}),
228
+ ...(Array.isArray(subColumns) && subColumns.length > businessCodes.length ? { systemSubColumnCount: subColumns.length - businessCodes.length } : {}),
158
229
  ...(conditions.length > 0 ? { conditions } : {}),
159
230
  ...(field.shapeWarning !== undefined ? { shapeWarning: field.shapeWarning } : {}),
160
231
  };
@@ -202,6 +273,7 @@ export function registerFieldLane(tools) {
202
273
  type: field.type,
203
274
  ...(field.typeName !== undefined ? { typeName: field.typeName } : {}),
204
275
  ...(field.name !== undefined ? { name: field.name } : {}),
276
+ ...(Array.isArray(field.subColumns) ? { subColumns: field.subColumns } : {}),
205
277
  ...(conditions.length > 0 ? { conditions } : {}),
206
278
  ...(field.shapeWarning !== undefined ? { shapeWarning: field.shapeWarning } : {}),
207
279
  };
@@ -225,7 +297,7 @@ export function registerFieldLane(tools) {
225
297
  const draft = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "false" } })).data);
226
298
  const published = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
227
299
  if ([...draft, ...published].some((row) => (row.code ?? row.propertyCode) === args.code))
228
- throw new ToolRefusalError({ status: "refused", blocker: "duplicate-field", schemaCode: args.schemaCode, propertyCode: args.code, message: `字段 ${args.schemaCode}.${args.code} 已存在于草稿或发布面;拒绝重复 POST` });
300
+ throw new ToolRefusalError({ status: "refused", blocker: "duplicate-field", schemaCode: args.schemaCode, propertyCode: args.code, message: `字段 ${args.schemaCode}.${args.code} 已存在于草稿或发布面;拒绝重复 POST`, nextCommand: `omc call field.list --args '${JSON.stringify({ schemaCode: args.schemaCode, published: true, code: args.code })}'` });
229
301
  // One seam owns field-change normalization, every reproduced gate, and the
230
302
  // wire body (src/field-change.ts), so configure_model and intent share it.
231
303
  const prepared = prepareFieldChange(args);
@@ -302,6 +302,21 @@ export function registerListViewLane(tools) {
302
302
  conditions,
303
303
  actionCodes: readArray(model, "queryActions").map((action) => action.actionCode).filter((value) => value !== undefined),
304
304
  buttonCount: readArray(model, "customButtons").length,
305
+ // r14 F5 / r11 #6: the default face exposed only `buttonCount`, so a
306
+ // caller could not prove WHICH buttons landed (it had to trust the
307
+ // configure readback or cross-check rule.list). Codes + bindings cost
308
+ // ~40 B per button and answer "did my buttons land?"; the full
309
+ // script/actionConfig payload stays behind `buttons:true`.
310
+ buttonCodes: readArray(model, "customButtons").map((button) => button.code).filter((value) => typeof value === "string"),
311
+ buttonBindings: readArray(model, "customButtons")
312
+ .filter((button) => typeof button.code === "string")
313
+ .map((button) => ({
314
+ code: button.code,
315
+ ...(button.bindAction !== undefined ? { bindAction: button.bindAction } : {}),
316
+ ...(button.targetCode !== undefined ? { targetCode: button.targetCode } : {}),
317
+ ...(button.targetModel !== undefined ? { targetModel: button.targetModel } : {}),
318
+ ...(button.targetForm !== undefined ? { targetForm: button.targetForm } : {}),
319
+ })),
305
320
  // Button scripts live in h_biz_button.actionConfig (a JSON STRING)
306
321
  // whose `scriptData` holds the source for bindAction=SCRIPT. Surfacing
307
322
  // them here removes the "listview.get → artifact → JSON.parse →
@@ -776,7 +791,27 @@ export function registerListViewLane(tools) {
776
791
  const before = await wireCall(session, { path: ENDPOINTS.listQueryGetHeader, query: { schemaCode: args.schemaCode, code: args.code } });
777
792
  if (before.data == null)
778
793
  return { performed: { skipped: "already-absent" }, readback: { absent: true, header: null } };
779
- const performed = await wireCall(session, { path: ENDPOINTS.listQueryDelete, method: "POST", query: { schemaCode: args.schemaCode, code: args.code } });
794
+ let performed;
795
+ try {
796
+ performed = await wireCall(session, { path: ENDPOINTS.listQueryDelete, method: "POST", query: { schemaCode: args.schemaCode, code: args.code } });
797
+ }
798
+ catch (error) {
799
+ // 306103 删除后将无可用视图 — the platform requires at least one visible
800
+ // list view per model. r15's `nextCommand` fell back to the generic
801
+ // refusal router; name the constraint and the only real deletion path.
802
+ if (error instanceof WireError && error.kind === "business" && (error.errcode === 306103 || /无可用视图/.test(error.message)))
803
+ throw new ToolRefusalError({
804
+ status: "refused",
805
+ blocker: "listview-last-visible",
806
+ schemaCode: args.schemaCode,
807
+ code: args.code,
808
+ nextCommand: `omc model-purge --code ${args.schemaCode}`,
809
+ message: `不能删除视图 ${args.code}:平台要求每个模型至少保留一条可见列表视图(306103)。` +
810
+ `单独删最后一条视图没有意义——视图会随模型一起级联删除。要继续用这个模型就**就地改**(listview.configure 省略 code 即改默认视图),` +
811
+ `要整体清理就用 model-purge(默认 dry-run,先看计划)。`,
812
+ });
813
+ throw error;
814
+ }
780
815
  const readback = await wireCall(session, { path: ENDPOINTS.listQueryGetHeader, query: { schemaCode: args.schemaCode, code: args.code } });
781
816
  return { performed: { errcode: performed.errcode }, readback: { absent: readback.data == null, header: readback.data ?? null } };
782
817
  }, undefined, undefined, listviewRollbackSpec("listview.delete"));
@@ -199,7 +199,10 @@ export function registerModelLane(tools) {
199
199
  const tree = flattenTreeRows(wireRows((await wireCall(session, { path: ENDPOINTS.applicationTree, query: { appCode: applicationCode } })).data));
200
200
  const all = tree
201
201
  .filter((row) => row.type === "BizModel" && typeof row.code === "string" && row.code)
202
- .map((row) => ({ code: row.code, name: row.name ?? row.text ?? null }));
202
+ // `schemaCode` is the parameter name every other tool takes; `code` is
203
+ // the tree row's key. Echo both so `--jq 'models[].schemaCode'` works
204
+ // (r15 P6: the alias was missing and the path missed).
205
+ .map((row) => ({ code: row.code, schemaCode: row.code, name: row.name ?? row.text ?? null }));
203
206
  const needle = args.filter?.trim();
204
207
  const models = needle ? all.filter((model) => String(model.code).includes(needle) || String(model.name ?? "").includes(needle)) : all;
205
208
  // `filterApplied` disambiguates an empty result: it distinguishes "the