@stalfh233/omc-cli 0.5.7 → 0.5.9

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/dist/cli.js CHANGED
@@ -1405,8 +1405,10 @@ function cmdHelpSearch(topic, query, transport, searchMode, limit) {
1405
1405
  sectionCount: outline.sections.length,
1406
1406
  // Each section carries its price inline. `{"heading":"入口","bytes":282}`
1407
1407
  // is cheaper than parallel arrays and reads better than a heading→bytes map;
1408
- // the heading names are what `--section` accepts.
1409
- sections: outline.sections.map((entry) => ({ heading: entry.heading, bytes: entry.bytes })),
1408
+ // the heading names are what `--section` accepts. The blurb carries the
1409
+ // section's first line so a bare index read still transfers key pointers
1410
+ // (r36: four bare reads, zero section opens → body-only knowledge invisible).
1411
+ sections: outline.sections.map((entry) => ({ heading: entry.heading, bytes: entry.bytes, ...(entry.blurb !== undefined ? { blurb: entry.blurb } : {}) })),
1410
1412
  hint: "默认只回节索引:先按各节 bytes 挑要读的节,再用 --section 取正文(可重复或逗号一次多节,也可用节序号或唯一前缀)。确需整篇才 --all。",
1411
1413
  sectionCommand: `omc help ${outline.document.name} --section "<heading>"`,
1412
1414
  fullCommand: `omc help ${outline.document.name} --all`,
@@ -1745,6 +1747,10 @@ async function main() {
1745
1747
  .option("--environment <environment>")
1746
1748
  .option("--no-form", "跳过表单重建")
1747
1749
  .option("--no-list", "跳过列表加列")
1750
+ .option("--conditions <json>", "列表固定过滤条件 JSON 数组(listview.configure 契约,增量合并)")
1751
+ .option("--conditions-json <json>")
1752
+ .option("--demo-rows <json>", "演示数据行 JSON 数组(每项一份 formData,逐行 data.submit;非幂等)")
1753
+ .option("--demo-rows-json <json>")
1748
1754
  .option("--force-publish", "有字段失败也发布(默认停在发布前)")
1749
1755
  .option("--apply")
1750
1756
  .addHelpText("after", `\n存量模型加字段一条命令:建字段 → 发布字段 → 重建表单 → 列表追加列(增量合并,保留既有列/条件/排序)。\n默认 dry-run 只列计划;--apply 执行。幂等:字段已存在记 skipped 继续。\n子表不属于本命令(存量挂子表走 field.create CHILD_TABLE + 子列,见 model-scaffold)。\n\nr30/r31 探针实证:同一条链手工要走 4 次写调用 + 各自回读。\n`)
@@ -1759,6 +1765,10 @@ async function main() {
1759
1765
  ...(options.environment ? ["--environment", options.environment] : []),
1760
1766
  ...(options.form === false ? ["--no-form"] : []),
1761
1767
  ...(options.list === false ? ["--no-list"] : []),
1768
+ ...(options.conditions !== undefined ? ["--conditions", options.conditions] : []),
1769
+ ...(options.conditionsJson !== undefined ? ["--conditions-json", options.conditionsJson] : []),
1770
+ ...(options.demoRows !== undefined ? ["--demo-rows", options.demoRows] : []),
1771
+ ...(options.demoRowsJson !== undefined ? ["--demo-rows-json", options.demoRowsJson] : []),
1762
1772
  ...(options.forcePublish ? ["--force-publish"] : []),
1763
1773
  ...(options.apply ? ["--apply"] : [])];
1764
1774
  commandExitCode = await cmdFieldAdd(rest);
package/dist/field-add.js CHANGED
@@ -17,6 +17,70 @@ import { argvValue } from "./args.js";
17
17
  import { runToolCall } from "./call.js";
18
18
  import { CLI_EXIT_CODES, classifyResult, envelope, refusalText, requestId } from "./cli-contract.js";
19
19
  import { writeEnvelope } from "./cli-output.js";
20
+ /**
21
+ * 字典简写展开(r30/r36 实证:字典字段要先 names 查 id、records 取记录、
22
+ * 再拼 optionsSet.dictionary 三段 4–5 调;`dictCode` 把它折进 field-add 一步):
23
+ * `{"code":"x","propertyType":"DROPDOWN_BOX","dictCode":"purchase_coop_status"}`
24
+ * → 内部解析 checkedDictionary(id)+checkedDictionaryCode+useDictionariesData(records)
25
+ * 按 dictionary-backed-single-select recipe 形状组装 options.optionsSet。
26
+ */
27
+ async function expandDictShorthand(fields, environment, contextForTest, request) {
28
+ const problems = [];
29
+ const expanded = [];
30
+ const call = async (tool, args) => {
31
+ const outcome = await runToolCall(tool, {
32
+ args,
33
+ requestId: request,
34
+ ...(environment !== undefined ? { environment } : {}),
35
+ ...(contextForTest !== undefined ? { context: contextForTest } : {}),
36
+ print: () => undefined,
37
+ });
38
+ return outcome.exitCode === 0 ? outcome.result : null;
39
+ };
40
+ const out = [];
41
+ for (const field of fields) {
42
+ const dictCode = field.dictCode;
43
+ if (typeof dictCode !== "string" || dictCode.length === 0) {
44
+ out.push({ ...field });
45
+ continue;
46
+ }
47
+ const rest = { ...field };
48
+ delete rest.dictCode;
49
+ // dictionary.list 是真假两平台都稳的清单面(names 的 getDictionaryIdAndNames
50
+ // 在 fake 上未实现);list 的回执可能是 {dictionaries:{content:[...]}} 分页
51
+ // 或裸数组,两种都吃。
52
+ const listResult = await call("dictionary.list", { limit: 200 });
53
+ const listPayload = listResult === null ? null : (listResult.dictionaries ?? listResult.names ?? listResult.content ?? listResult);
54
+ const names = Array.isArray(listPayload)
55
+ ? listPayload
56
+ : (Array.isArray(listPayload?.content) ? listPayload.content : []);
57
+ const hit = names.find((row) => String(row.code ?? row.dictionaryCode ?? "") === dictCode);
58
+ if (hit === undefined) {
59
+ problems.push(`${String(field.code)}: 字典 ${dictCode} 不存在(dictionary.names 未命中)——先 dictionary.save 或修正 dictCode`);
60
+ continue;
61
+ }
62
+ const dictId = String(hit.id ?? hit.dictionaryId ?? "");
63
+ const recordsResult = await call("dictionary.records", { code: dictCode, enabledOnly: true });
64
+ const recordsRaw = recordsResult === null ? [] : (recordsResult.records ?? {});
65
+ const recordRows = (Array.isArray(recordsRaw) ? recordsRaw : (recordsRaw.content ?? []));
66
+ if (recordRows.length === 0) {
67
+ problems.push(`${String(field.code)}: 字典 ${dictCode} 无启用记录——先补 dictionary.records`);
68
+ continue;
69
+ }
70
+ const useDictionariesData = recordRows.map((row) => ({ id: String(row.id ?? ""), code: String(row.code ?? ""), name: String(row.name ?? row.code ?? "") }));
71
+ out.push({
72
+ ...rest,
73
+ options: {
74
+ optionsSet: {
75
+ optionsType: "dictionary",
76
+ dictionary: { checkedDictionary: dictId, checkedDictionaryCode: dictCode, useDictionariesData },
77
+ },
78
+ },
79
+ });
80
+ expanded.push(String(field.code));
81
+ }
82
+ return { fields: out, expanded, problems };
83
+ }
20
84
  export function validateFields(schemaCode, fields) {
21
85
  const problems = [];
22
86
  const seen = new Set();
@@ -50,12 +114,17 @@ export async function cmdFieldAdd(args, contextForTest) {
50
114
  const publishForm = !args.includes("--no-form");
51
115
  const addToList = !args.includes("--no-list");
52
116
  const forcePublish = args.includes("--force-publish");
117
+ const conditionsJson = flag("--conditions") ?? flag("--conditions-json");
118
+ const demoRowsJson = flag("--demo-rows") ?? flag("--demo-rows-json");
53
119
  const usage = [
54
120
  "用法:omc field-add --schema <模型code> --fields '<JSON 数组>' [--apply]",
55
- " --fields 每项是一个 field.create 的参数:{\"code\",\"name\",\"propertyType\",...}(choices/字典 options 原样透传)。",
121
+ " --fields 每项是一个 field.create 的参数:{\"code\",\"name\",\"propertyType\",...}(choices/字典 options 原样透传);",
122
+ " 字典简写:{\"dictCode\":\"<字典code>\"} 代替手拼 options.optionsSet(内部解析字典 id+启用记录,recipe 同形状)。",
56
123
  " 默认 dry-run;--apply 依次执行 建字段 → 发布字段 → 重建表单 → 列表追加列。",
57
124
  " 幂等:字段已存在记 skipped 继续;有真失败默认不发布/不重建(--force-publish 强发)。",
58
125
  " --no-form 跳过表单重建;--no-list 跳过列表加列(默认两者都做)。",
126
+ " --conditions '<JSON 数组>' 列表固定过滤条件(listview.configure 契约;**整体替换**既有固定条件——要保留旧的须一并带上;不传则不动);需 --list(默认开)。",
127
+ " --demo-rows '<JSON 数组>' 演示数据行(每项一份 formData,逐行 data.submit);非幂等——重复执行会重复插行。",
59
128
  ].join("\n");
60
129
  const invalid = (message) => {
61
130
  writeEnvelope(envelope("error", {}, request, { code: "invalid-input", message: `${message}\n${usage}`, retryable: false }));
@@ -78,6 +147,45 @@ export async function cmdFieldAdd(args, contextForTest) {
78
147
  const problems = validateFields(schemaCode, fields);
79
148
  if (problems.length > 0)
80
149
  return invalid(`--fields 有 ${problems.length} 处问题:\n - ${problems.join("\n - ")}`);
150
+ // r38 后执行层折叠(调查批 v2):--conditions/--demo-rows 把「加字段+过滤+演示行」
151
+ // 整链折进一条命令(此前 6-8 调)。conditions 走 listview.configure 契约;
152
+ // demo-rows 逐行 data.submit(非幂等,文档声明)。
153
+ let conditions;
154
+ if (conditionsJson !== undefined) {
155
+ if (!addToList)
156
+ return invalid("--conditions 需要列表配置面(不要与 --no-list 同用)");
157
+ try {
158
+ const parsed = JSON.parse(conditionsJson);
159
+ if (!Array.isArray(parsed) || parsed.length === 0)
160
+ throw new Error("必须是非空 JSON 数组");
161
+ conditions = parsed;
162
+ }
163
+ catch (error) {
164
+ return invalid(`--conditions 解析失败:${error instanceof Error ? error.message : String(error)}(形状见 omc describe listview.configure --param conditions)`);
165
+ }
166
+ }
167
+ let demoRows;
168
+ if (demoRowsJson !== undefined) {
169
+ try {
170
+ const parsed = JSON.parse(demoRowsJson);
171
+ if (!Array.isArray(parsed) || parsed.length === 0)
172
+ throw new Error("必须是非空 JSON 数组");
173
+ demoRows = parsed;
174
+ }
175
+ catch (error) {
176
+ return invalid(`--demo-rows 解析失败:${error instanceof Error ? error.message : String(error)}`);
177
+ }
178
+ }
179
+ // 字典简写展开(dictCode → optionsSet.dictionary,含 id/记录解析)——展开失败
180
+ // 在任何写之前拒绝(名字对不上/无启用记录)。
181
+ let dictExpanded = 0;
182
+ if (fields.some((field) => typeof field.dictCode === "string")) {
183
+ const expandedResult = await expandDictShorthand(fields, environment, contextForTest, request);
184
+ if (expandedResult.problems.length > 0)
185
+ return invalid(`dictCode 展开失败(未写入):\n - ${expandedResult.problems.join("\n - ")}`);
186
+ fields = expandedResult.fields;
187
+ dictExpanded = expandedResult.expanded.length;
188
+ }
81
189
  const options = {
82
190
  schemaCode, fields, apply, publishForm, addToList, forcePublish,
83
191
  ...(environment !== undefined ? { environment } : {}),
@@ -115,11 +223,14 @@ export async function cmdFieldAdd(args, contextForTest) {
115
223
  status: "dry-run",
116
224
  schema: schemaCode,
117
225
  fields: fields.map((field) => String(field.code)),
226
+ ...(dictExpanded > 0 ? { dictExpanded } : {}),
118
227
  steps: [
119
228
  `field.create ×${fields.length}`,
120
229
  "field.publish(发布失败/有字段失败则停)",
121
230
  ...(publishForm ? [`form.publish ${schemaCode} --rebuild(重建让新字段进默认表单)`] : []),
122
- ...(addToList ? [`listview.configure ${schemaCode} 增量追加列(保留既有列与条件)`] : []),
231
+ ...(addToList ? [`listview.configure ${schemaCode} 增量追加列(列保留;固定条件整体替换)`] : []),
232
+ ...(conditions !== undefined ? [`listview.configure 附带固定过滤条件 ×${conditions.length}`] : []),
233
+ ...(demoRows !== undefined ? [`data.submit 演示行 ×${demoRows.length}(非幂等)`] : []),
123
234
  ],
124
235
  preflight: { checked: fields.length, passed: true },
125
236
  note: "dry-run 只列计划;已对每个字段跑过本地形状预检(零 wire)。--apply 依次执行全部步骤。",
@@ -199,21 +310,38 @@ export async function cmdFieldAdd(args, contextForTest) {
199
310
  }
200
311
  // 4) 列表增量加列:默认合并语义保留既有列/条件/排序(r31 实证);只有字段全部
201
312
  // 失败才跳过(部分成功时把成功的列加上去仍有意义)。
202
- if (addToList && createdCodes.length > 0 && !publishingBlocked) {
203
- const configured = await call("listview.configure", { schemaCode, fieldCodes: createdCodes });
313
+ const listviewArgs = { schemaCode };
314
+ if (createdCodes.length > 0)
315
+ listviewArgs.fieldCodes = createdCodes;
316
+ if (conditions !== undefined)
317
+ listviewArgs.conditions = conditions;
318
+ if (addToList && (createdCodes.length > 0 || conditions !== undefined) && !publishingBlocked) {
319
+ const configured = await call("listview.configure", listviewArgs);
204
320
  steps.push({
205
321
  step: "listview.configure",
206
322
  status: configured.ok ? "ok" : "failed",
207
323
  detail: configured.ok
208
- ? { schemaCode, appended: createdCodes, semantics: "增量合并:保留既有列/条件/排序" }
209
- : { schemaCode, fieldCodes: createdCodes, message: String(configured.result.message ?? "") },
324
+ ? { schemaCode, ...(createdCodes.length > 0 ? { appended: createdCodes } : {}), ...(conditions !== undefined ? { conditions: conditions.length, conditionsSemantics: "整体替换(既有固定条件要保留须一并带上)" } : {}), semantics: "列增量合并保留既有列/排序;固定条件整体替换" }
325
+ : { schemaCode, ...listviewArgs, message: String(configured.result.message ?? "") },
210
326
  });
211
327
  }
212
328
  else if (addToList) {
213
329
  steps.push({
214
330
  step: "listview.configure",
215
331
  status: "skipped",
216
- detail: { reason: createdCodes.length === 0 ? "没有字段落到发布面" : "有字段创建失败,默认不动列表(--force-publish 可强发)" },
332
+ detail: { reason: createdCodes.length === 0 && conditions === undefined ? "没有字段落到发布面" : "有字段创建失败,默认不动列表(--force-publish 可强发)" },
333
+ });
334
+ }
335
+ // 演示数据(--demo-rows):逐行 data.submit;失败记 failed 不阻断(行级独立)。
336
+ for (const [index, row] of (demoRows ?? []).entries()) {
337
+ const submitted = await call("data.submit", { schemaCode, formData: row });
338
+ const readback = submitted.ok ? (submitted.result.readback ?? {}) : {};
339
+ steps.push({
340
+ step: "data.submit",
341
+ status: submitted.ok ? "ok" : "failed",
342
+ detail: submitted.ok
343
+ ? { row: index + 1, bizObjectId: readback.objectId ?? null, sequenceStatus: readback.sequenceStatus ?? null }
344
+ : { row: index + 1, message: String(submitted.result.message ?? "") },
217
345
  });
218
346
  }
219
347
  const failedSteps = steps.filter((step) => step.status === "failed");
@@ -230,6 +358,20 @@ export async function cmdFieldAdd(args, contextForTest) {
230
358
  ...(lastSummaries.length > 0 ? { summaries: lastSummaries } : {}),
231
359
  steps,
232
360
  folded: "field.create → field.publish → form.publish(rebuild) → listview.configure(增量) 4 写调合成 1 条命令",
361
+ ...(status === "ok"
362
+ ? {
363
+ hint: [
364
+ "本命令覆盖单模型加字段;变更还涉及多模型/规则/流程/视图重排时,改走声明式 intent 链(omc intent schema 看清单 → validate → plan → apply,dry-run→读回校验)",
365
+ // r39 实证:agent 做完字段后把过滤/演示行拆成 listview.configure×N + data.submit×N
366
+ // 串行做。回执直接给续段一条命令——conditions 增量幂等可单独重跑,demo-rows 非幂等。
367
+ ...(conditions === undefined || demoRows === undefined
368
+ ? [
369
+ `同模型还要列表固定过滤/演示数据?直接续一条(不必另跑 listview.configure/data.submit):omc field-add --schema ${schemaCode} ${conditions === undefined ? "--conditions '<固定过滤>' " : ""}${demoRows === undefined ? "--demo-rows '<演示行>' " : ""}--apply(conditions 增量幂等;demo-rows 非幂等重复插行)`,
370
+ ]
371
+ : []),
372
+ ].join(";"),
373
+ }
374
+ : {}),
233
375
  };
234
376
  const classified = classifyResult(status === "partial" ? { ...result, requested: steps.length, succeeded: steps.length - failedSteps.length } : result);
235
377
  writeEnvelope(envelope(classified.status, result, request, classified.error));
@@ -1147,6 +1147,15 @@ export async function executeFormDraftWrite(session, args) {
1147
1147
  }
1148
1148
  : {}),
1149
1149
  ...(args.rebuild === true && previousAttributes ? { rebuildPreservedControls } : {}),
1150
+ // r36(2026-11-01):rebuild 后草稿与重建前逐字节一致时明示——rebuild 的
1151
+ // 语义就是"按已发布字段重搭",一致通常意味着预期的新控件没进来(字段未
1152
+ // 发布/写错槽位),静默 no-op 会让"新增字段后刷新表单"看起来成功实则没变。
1153
+ ...(args.rebuild === true && previousAttributes !== undefined && typeof attributes === "string" && attributes.trim() === JSON.stringify(previousAttributes).trim()
1154
+ ? {
1155
+ rebuildUnchanged: true,
1156
+ rebuildNote: "rebuild 后草稿与重建前完全一致——若你预期新增控件(新字段上表单),检查:① 字段是否已 field.publish;② 写入槽位 sheetCode 是否正确(省略=模型 code 槽位);r36 实测同序列下重建静默 no-op。",
1157
+ }
1158
+ : {}),
1150
1159
  },
1151
1160
  };
1152
1161
  }
@@ -383,9 +383,9 @@ const COMPOSER_ROUTES = Object.freeze([
383
383
  },
384
384
  {
385
385
  id: "field-add",
386
- command: "omc field-add --schema <存量模型code> --fields '<JSON数组>' --apply",
386
+ command: "omc field-add --schema <存量模型code> --fields '<JSON数组>' [--conditions '<固定过滤>'] [--demo-rows '<演示行>'] --apply",
387
387
  usage: "omc field-add --help",
388
- summary: "存量模型加字段一条命令:建字段 → 发布 → 重建表单 → 列表追加列(增量合并);4 次写调合成 1 条命令",
388
+ summary: "存量模型加字段一条命令:建字段 → 发布 → 重建表单 → 列表追加列;--conditions 固定过滤 + --demo-rows 演示行同链折入——字段+过滤+演示数据的多段需求整句一次问,4-8 次调用合成 1 条命令",
389
389
  // (add verb) × (field noun)——「给既有/存量模型加字段」是变更类需求的最高频动作(r30/r31 探针实证手工链 4 调)
390
390
  groups: [["加", "新增", "添加", "补", "增加"], ["字段"]],
391
391
  // 只收「加字段本体」;规则/校验/计算/编号/流程语义各有归宿(round10:'给金额字段加自动校验规则' 应归 datarule,不能被本 composer 抢走)
@@ -452,7 +452,7 @@ export const COMPOSER_ROUTES_FOR_TEST = COMPOSER_ROUTES;
452
452
  const COMPOSER_QUESTIONS = Object.freeze({
453
453
  "model-purge": "要清理哪个模型?给我 schemaCode(不确定就先跑下面的只读判别动作拿清单)",
454
454
  "model-scaffold": "给我模型 code、显示名和字段清单(每字段 code/名称/类型;子表放 subFields),我一条命令建完",
455
- "field-add": "目标存量模型 schemaCode 和字段清单(每字段 code/名称/类型;choices 给字符串数组、字典给 options)是什么?建完自动发布、重建表单、列表加列",
455
+ "field-add": "目标存量模型 schemaCode 和字段清单(每字段 code/名称/类型;choices 给字符串数组、字典 dictCode 简写)是什么?需求还带列表固定过滤或演示行的话**一并整句给我——全折进本命令(--conditions/--demo-rows),不要拆成字段/过滤/数据三段串行做**;建完自动发布、重建表单、列表加列",
456
456
  "approval-flow": "目标模型 schemaCode 和每级审批节点(节点名@角色code,多级用重复 --steps)是什么?",
457
457
  compute: "目标模型 schemaCode、结果字段 code、公式是什么?(子表条件聚合再加 --where \"子列 = 值\")",
458
458
  "rule-when": "目标模型 schemaCode、触发时机(Create/Update…)、条件(--when)和动作(--then)各是什么?",
@@ -126,7 +126,7 @@ function phraseOf(tool, args, outcome) {
126
126
  case "field.publish":
127
127
  return `发布模型 ${schemaCode ?? "?"} 的全部草稿字段(模型级一次发布)`;
128
128
  case "form.draft":
129
- return `保存表单 ${stringArg(args, "sheetCode") ?? "?"}(模型 ${schemaCode ?? "?"})的草稿布局`;
129
+ return `保存表单 ${stringArg(args, "sheetCode") ?? (schemaCode !== undefined ? String(schemaCode) : "?")}(模型 ${schemaCode ?? "?"})的草稿布局——sheetCode 省略时实际写入 = 模型 code 槽位`;
130
130
  case "form.publish":
131
131
  // r26 finding: sheetCode is optional (resolved from schemaCode for the
132
132
  // default form) — the summary showed "?" when the agent omitted it.
package/dist/journal.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { invalidateProjectCard } from "./project-card.js";
3
4
  /**
4
5
  * Per-environment append-only journal of delivered governed config writes.
5
6
  *
@@ -26,6 +27,9 @@ export function appendJournalEntry(artifactsDir, entry) {
26
27
  const seq = countLines(file) + 1;
27
28
  const full = { ...entry, seq, ts: new Date().toISOString() };
28
29
  appendFileSync(file, `${JSON.stringify(full)}\n`, "utf8");
30
+ // 2026-11-01 项目卡失效:模型面写操作改变清单——下次会话不得读旧卡。
31
+ if (/^model\.(create|delete|move|update)/.test(entry.tool))
32
+ invalidateProjectCard(artifactsDir);
29
33
  return full;
30
34
  }
31
35
  export function readJournal(environment, artifactsDir) {
@@ -722,6 +722,10 @@ export function registerB2Lane(tools) {
722
722
  }
723
723
  }
724
724
  return { adapters, rawPresent: typeof result.data === "string" };
725
+ }, {
726
+ // 2026-11-01 读门 2048:适配器面本身就是这个工具的存在意义(注册预检的
727
+ // 同源依据)——compact 保留 adapters 本体,不折码表。
728
+ compact: (result) => result,
725
729
  }),
726
730
  }));
727
731
  tools.push(defineTool({
@@ -88,8 +88,22 @@ export function registerBusinessRuleLane(tools) {
88
88
  // key (schemaCode+ruleCode), so the row id is rarely needed.
89
89
  const fullRows = args.full === true;
90
90
  const visible = args.onlyCustom === true ? rows.filter((row) => row.defaultRule !== true) : rows;
91
+ // r40 维护者自踩:schemaCode 不存在时平台静默回空数组——空规则被读成
92
+ // 『模型在、无规则』走错分支(mcc_contract 从不存在却答 0 规则)。
93
+ // 空结果时用 field.list 探一次真身(300004=不存在),把 modelPresent 标进结果。
94
+ let modelPresent = true;
95
+ if (visible.length === 0) {
96
+ try {
97
+ await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } });
98
+ }
99
+ catch (error) {
100
+ if (String(error.message ?? error).includes("errcode 300004"))
101
+ modelPresent = false;
102
+ }
103
+ }
91
104
  return {
92
105
  schemaCode: args.schemaCode,
106
+ ...(modelPresent ? {} : { modelPresent: false, hint: "模型不存在(field.list 300004)——空规则是假象;先 model.create / configure_model,判定模型面用 model.inspect" }),
93
107
  ...(args.onlyCustom === true ? { filtered: { onlyCustom: true, total: rows.length, returned: visible.length } } : {}),
94
108
  rules: visible.map((row) => ({
95
109
  code: row.code,
@@ -7,6 +7,7 @@ import { flattenTreeRows } from "../support.js";
7
7
  import { defineTool } from "../tool-types.js";
8
8
  import { wireCall, wireRows } from "../wire.js";
9
9
  import { ENVIRONMENT_LANE_CONTRACT } from "./environment-contract.js";
10
+ import { readProjectCard, writeProjectCard } from "../project-card.js";
10
11
  const ENVIRONMENT_LANE = ENVIRONMENT_LANE_CONTRACT.lane;
11
12
  const ENVIRONMENT_TOOLS = ENVIRONMENT_LANE_CONTRACT.tools;
12
13
  export function registerEnvironmentLane(tools) {
@@ -22,9 +23,17 @@ export function registerEnvironmentLane(tools) {
22
23
  applicationCode: z.string().optional(),
23
24
  modelCode: z.string().optional(),
24
25
  full: z.boolean().optional().describe("内联完整发现结果;默认返回计数与 detailFile"),
26
+ refresh: z.boolean().optional().describe("跳过项目卡缓存强制重查(模型清单有变更但未被自动失效时用)"),
25
27
  capability: capabilityArg,
26
28
  },
27
29
  handler: async (ctx, args, self) => governedRead(ctx, self, args, async (session) => {
30
+ // 2026-11-01 项目卡(预算压缩批 v1):新鲜缓存命中时一次本地读替代
31
+ // 2–3 次 wire(r31–r37 每会话重新盘点的纯浪费)。refresh:true 强制重查。
32
+ if (args.refresh !== true && args.applicationCode === undefined && args.modelCode === undefined && args.full !== true) {
33
+ const cached = readProjectCard(ctx.artifactsDir, String(args.environment ?? session.environmentName ?? ""));
34
+ if (cached !== undefined)
35
+ return { ...cached, status: "ok", fromCache: true };
36
+ }
28
37
  // List every application the tenant exposes — the configured
29
38
  // applicationCode is a default, never the only application. Resolve
30
39
  // the active code through the same priority chain as writes:
@@ -120,7 +129,7 @@ export function registerEnvironmentLane(tools) {
120
129
  .filter((row) => !args.modelCode || row.code === args.modelCode || row.schemaCode === args.modelCode)
121
130
  .map((row) => ({ schemaCode: row.code ?? row.schemaCode, name: row.name }));
122
131
  }
123
- return {
132
+ const discovered = {
124
133
  applications: apps,
125
134
  groups,
126
135
  applicationCode: selected ?? null,
@@ -130,6 +139,21 @@ export function registerEnvironmentLane(tools) {
130
139
  foldersAndModels,
131
140
  models,
132
141
  };
142
+ // 项目卡写入(软失败):默认形态(无收窄参数)时缓存模型清单,下次会话一次本地读。
143
+ if (args.applicationCode === undefined && args.modelCode === undefined && args.full !== true) {
144
+ const envName = String(args.environment ?? session.environmentName ?? "");
145
+ if (envName !== "" && Array.isArray(discovered.models) && discovered.models.length > 0 && typeof selected === "string" && selected !== "") {
146
+ writeProjectCard(ctx.artifactsDir, {
147
+ environment: envName,
148
+ applicationCode: selected,
149
+ ...(applicationResolution !== undefined ? { applicationResolution: { code: String(selected ?? applicationResolution.requested ?? ""), source: String(applicationResolution.source ?? "") } } : {}),
150
+ models: discovered.models.slice(0, 300).map((row) => ({ code: String(row.schemaCode), name: String(row.name ?? "") })),
151
+ modelCount: discovered.models.length,
152
+ generatedAt: new Date().toISOString(),
153
+ });
154
+ }
155
+ }
156
+ return discovered;
133
157
  }, {
134
158
  full: args.full === true,
135
159
  target: `discover_environment:${args.applicationCode ?? ""}:${args.modelCode ?? ""}`,
@@ -143,6 +143,24 @@ export function registerFormLane(tools) {
143
143
  publishedControlCodes: publishedControls,
144
144
  ...(wanted ? { draftControls: pick(sheet.draftAttributesJson), publishedControls: pick(sheet.publishedAttributesJson) } : {}),
145
145
  };
146
+ }, {
147
+ // 2026-11-01 读门 2048:controls=true 是**显式**取控件定义的请求——compact
148
+ // 保留定义映射与按钮/options(--full 的语义面),其余大数组归码表/计数。
149
+ compact: (result) => {
150
+ const KEEP = new Set(["draftControls", "publishedControls", "customButtonJson", "options", "formButtons", "formButtonCodes"]);
151
+ const out = {};
152
+ for (const [key, value] of Object.entries(result)) {
153
+ if (KEEP.has(key))
154
+ out[key] = value;
155
+ else if (Array.isArray(value))
156
+ out[`${key}Codes`] = value.map((entry) => (entry && typeof entry === "object" ? String(entry.code ?? entry.propertyCode ?? "") : String(entry))).filter((entry) => entry.length > 0), out[`${key}Count`] = value.length;
157
+ else if (value !== null && typeof value === "object")
158
+ out[`${key}Count`] = Object.keys(value).length;
159
+ else
160
+ out[key] = value;
161
+ }
162
+ return out;
163
+ },
146
164
  }),
147
165
  }));
148
166
  writeTool(tools, "form.draft", FORM_LANE, FORM_TOOLS["form.draft"].scope, FORM_TOOLS["form.draft"].description, {
@@ -242,7 +260,23 @@ export function registerFormLane(tools) {
242
260
  });
243
261
  results.push({ step: "form.publish", errcode: publishOutcome.performed.errcode });
244
262
  const published = await wireCall(session, { path: ENDPOINTS.sheetGet, query: { schemaCode: args.schemaCode, sheetCode } });
245
- return { steps: results, readback: { formPublished: (published.data ?? {}).published === true, publishedHtmlJsonPresent: typeof (published.data ?? {}).publishedHtmlJson === "string" } };
263
+ const pub = (published.data ?? {});
264
+ const publishedControlCodes = parseControlCodes(pub.publishedAttributesJson);
265
+ // r40 F4:published=true ≠ 请求的字段进了布局——模型已有手工布局时,非 rebuild
266
+ // 草稿保留原控件,新字段不会出现,而 formPublished 仍是 true(假成功)。
267
+ // readback 必须核「请求面是否真的落进 published 控件」,缺了就点名+给重建路。
268
+ const missing = (args.fieldCodes ?? []).filter((code) => !publishedControlCodes.includes(code));
269
+ return {
270
+ steps: results,
271
+ readback: {
272
+ formPublished: pub.published === true,
273
+ publishedHtmlJsonPresent: typeof pub.publishedHtmlJson === "string",
274
+ publishedControlCodes,
275
+ ...(missing.length > 0
276
+ ? { missingFieldCodes: missing, warning: `表单已发布,但请求的 ${missing.join("、")} 不在 published 控件里(既有手工布局未带上新字段)——重跑 configure_form 带 rebuild:true(或 form.draft {rebuild:true} → form.publish)后重验本字段` }
277
+ : {}),
278
+ },
279
+ };
246
280
  },
247
281
  summarize: (args, _outcome, phase) => writeSummary("configure_form", args, undefined, phase),
248
282
  });
@@ -2,6 +2,30 @@ import { readFileSync } from "node:fs";
2
2
  import { readArtifact } from "./artifacts.js";
3
3
  import { artifactRoot } from "./command-transport.js";
4
4
  export function projectJsonPath(value, path) {
5
+ // r40 F9:`$.x`(jq 标准根写法)此前 pathMiss——agent/人都习惯带根前缀。
6
+ // 归一剥掉前导 `$.`/`$`,语义等价(本 DSL 根就是整份结果)。
7
+ if (path.startsWith("$."))
8
+ path = path.slice(2);
9
+ else if (path === "$")
10
+ path = "";
11
+ // 2026-11-01(r36 维护者实测):jq 切片 `a[0:6]`/`a[:n]`/`a[n:]` 此前
12
+ // pathMiss 且 hint 的 available 还把数组列成"可用键",误导下一步。数组
13
+ // 切片与 jq 语义一致(含头不含尾)。
14
+ const sliceSel = /\[(\d*):(\d*)\]/.exec(path);
15
+ if (sliceSel !== null) {
16
+ const [, rawFrom, rawTo] = sliceSel;
17
+ const before = path.slice(0, sliceSel.index);
18
+ const after = path.slice(sliceSel.index + sliceSel[0].length);
19
+ const base = projectJsonPath(value, before);
20
+ if (base.missed)
21
+ return base;
22
+ if (!Array.isArray(base.value))
23
+ return { missed: true, at: before || "$", available: `该层不是数组(是 ${base.value === null ? "null" : typeof base.value});切片 [from:to] 需要数组` };
24
+ const from = rawFrom === "" ? undefined : Number(rawFrom);
25
+ const to = rawTo === "" ? undefined : Number(rawTo);
26
+ const sliced = base.value.slice(from, to);
27
+ return after === "" ? { missed: false, value: sliced } : projectJsonPath(sliced, after);
28
+ }
5
29
  // 1.5 of issue 2026-10-23: an array-of-objects selection WITHOUT jq pipes.
6
30
  // `.rules[code=Create]` (or `[code="Create"]`) picks the FIRST element whose
7
31
  // key matches — exactly the shape callers reached for with
@@ -97,7 +121,7 @@ function argvValues(args, name) {
97
121
  return values;
98
122
  }
99
123
  /** Shared wording for every projection miss, so all three faces teach the same syntax. */
100
- const PATH_MISS_HINT = "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、数组映射 items[].fields.x、按键选择 rules[code=Create](返回第一个匹配项)。available 是该层真实存在的键。";
124
+ const PATH_MISS_HINT = "路径未命中(这一层没有该段)。可用写法:点式 a.b、下标 a[0]、切片 a[0:6]、数组映射 items[].fields.x、按键选择 rules[code=Create](返回第一个匹配项)。available 是该层真实存在的键。";
101
125
  /**
102
126
  * `call --extract/--extract-keys`: resolve paths inside the result's own
103
127
  * artifact (or inside the result object when the tool returned no artifact).
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Project card — one ~1KB local read that replaces the per-session 3–5 live
3
+ * wire calls of environment discovery (discover_environment → 2–3 wire reads;
4
+ * model.list → 1 wire read; r31–r37 every probe re-inventoried the platform).
5
+ *
6
+ * Design copied from describe-cache (`.omc/describe-full-cache.json`):
7
+ * an index file inside the artifacts dir points at an artifact body; a hit is
8
+ * soft (failure never breaks the main path), freshness = TTL + model-write
9
+ * invalidation; content changes that don't touch models (fields/rules/views)
10
+ * do NOT change the model inventory and don't invalidate.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { createArtifact, readArtifact } from "./artifacts.js";
15
+ const CARD_INDEX = "project-card.json";
16
+ const CARD_TTL_MS = 6 * 60 * 60 * 1000;
17
+ function indexPath(root) {
18
+ return path.join(root, CARD_INDEX);
19
+ }
20
+ /** Fresh card for this environment, or undefined (missing/stale/other env). */
21
+ export function readProjectCard(root, environment) {
22
+ try {
23
+ const index = JSON.parse(fs.readFileSync(indexPath(root), "utf8"));
24
+ if (index.environment !== environment)
25
+ return undefined;
26
+ if (Date.now() - index.at > CARD_TTL_MS)
27
+ return undefined;
28
+ const card = JSON.parse(readArtifact(root, index.artifactId).content.toString("utf8"));
29
+ return { ...card, fromCacheHint: `项目卡 ${new Date(index.at).toISOString()} 生成(TTL 6h,模型写操作失效);要强制重查传 refresh:true` };
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ /** Persist the card (artifact body + index). Soft-fail: never throws. */
36
+ export function writeProjectCard(root, card) {
37
+ try {
38
+ const artifact = createArtifact(root, JSON.stringify({ ...card, fromCacheHint: undefined }));
39
+ const index = { artifactId: artifact.artifactId, environment: card.environment, at: Date.now(), modelCount: card.modelCount };
40
+ fs.writeFileSync(indexPath(root), JSON.stringify(index), "utf8");
41
+ }
42
+ catch {
43
+ // The card is a pure optimization layer; failure to cache is not an error.
44
+ }
45
+ }
46
+ /** Invalidate on model-shape writes (create/delete) so a fresh session never reads a stale inventory. */
47
+ export function invalidateProjectCard(root) {
48
+ try {
49
+ fs.rmSync(indexPath(root), { force: true });
50
+ }
51
+ catch {
52
+ // best-effort
53
+ }
54
+ }
55
+ //# sourceMappingURL=project-card.js.map
package/dist/read.js CHANGED
@@ -56,7 +56,10 @@ export async function governedRead(ctx, tool, args, execute, options = {}) {
56
56
  if (ctx.readbackCacheBypass === 0 && cached && cached.environmentFingerprint === environmentFingerprint && cached.targetFingerprint === targetFingerprint && options.full !== true)
57
57
  return { status: "ok", unchanged: true, detailFile: cached.detailFile, detailSha256: cached.detailSha256, environmentFingerprint };
58
58
  const detail = { status: "ok", ...result, environmentFingerprint };
59
- const large = jsonBytes(result) > 4096;
59
+ // 2026-11-01 预算批 v2(r38 usage 实证):1-4KB 中结果 59 次/135KB≈37% 全内联,
60
+ // model.inspect 17 次全部卡在旧门下。降到 2048;compact 对配置清单保形为码表
61
+ // (CODE_LIST_KEYS),完整正文仍在 detailArtifactId。
62
+ const large = jsonBytes(result) > 2048;
60
63
  const fileName = `${tool.name.replace(/[^a-zA-Z0-9._-]/g, "_")}-${sha256(target).slice(0, 16)}.json`;
61
64
  const artifactRef = writeArtifact(ctx, fileName, detail);
62
65
  ctx.readbackCache.set(target, { environmentFingerprint, targetFingerprint, detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256 });
@@ -100,12 +103,29 @@ function compactReadback(result) {
100
103
  if (BULKY_READ_KEYS.has(key)) {
101
104
  compact[`${key}Count`] = Array.isArray(value) ? value.length : value && typeof value === "object" ? Object.keys(value).length : 0;
102
105
  }
106
+ else if (CODE_LIST_KEYS.has(key) && Array.isArray(value)) {
107
+ // 2026-11-01 预算批 v2:读门 4096→2048 后,中结果(r38 实测 model.inspect
108
+ // 17 次 51KB 全部卡旧门下全内联)走 compact——配置清单类数组保形为码表
109
+ // (不是只给 count),否则 agent 被迫二次 artifact get 反加回合。
110
+ compact[key] = value.map(codeOfEntry).filter((entry) => entry !== undefined);
111
+ compact[`${key}Count`] = value.length;
112
+ }
103
113
  else {
104
114
  compact[key] = summarizeValue(value);
105
115
  }
106
116
  }
107
117
  return compact;
108
118
  }
119
+ /** Config-inventory keys that compact to a code list (not a bare count). */
120
+ const CODE_LIST_KEYS = new Set(["fields", "columns", "conditions", "sorts", "datarules", "buttons", "views"]);
121
+ function codeOfEntry(entry) {
122
+ for (const key of ["propertyCode", "code", "schemaCode", "ruleCode", "fieldCode"]) {
123
+ const value = entry[key];
124
+ if (typeof value === "string" && value.length > 0)
125
+ return value;
126
+ }
127
+ return undefined;
128
+ }
109
129
  /**
110
130
  * Keys whose values are the bulk a compact projection replaces with a count.
111
131
  * Everything else is a small facet identity/decision fields (workflowCode,
package/dist/skills.js CHANGED
@@ -157,6 +157,23 @@ export function readSkillSection(name, requested) {
157
157
  * headings. An index costs ~600 B and names the price of each part, so the
158
158
  * caller chooses instead of paying for the whole document by default.
159
159
  */
160
+ /** First meaningful body line of a section: skip headings/blank/fence/list markers. */
161
+ function sectionBlurb(body) {
162
+ const limit = 70;
163
+ // sliceSectionText includes the section's own heading line — skip it.
164
+ for (const raw of body.split(/\r?\n/).slice(1)) {
165
+ const line = raw.trim();
166
+ if (line.length === 0)
167
+ continue;
168
+ if (line.startsWith("#") || line.startsWith("```") || line.startsWith("<!--") || line.startsWith("|"))
169
+ continue;
170
+ const text = line.replace(/^[-*]\s+/, "").replace(/^\d+\.\s*/, "").replace(/\*\*/g, "");
171
+ if (text.length === 0)
172
+ continue;
173
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
174
+ }
175
+ return undefined;
176
+ }
160
177
  export function readSkillOutline(name) {
161
178
  const document = readSkill(name);
162
179
  if (!document)
@@ -165,7 +182,7 @@ export function readSkillOutline(name) {
165
182
  const headings = scanHeadings(lines);
166
183
  const sections = headings.map((entry) => {
167
184
  const body = sliceSectionText(lines, entry.index, entry.level);
168
- return { heading: entry.heading, level: entry.level, bytes: Buffer.byteLength(body, "utf8") };
185
+ return { heading: entry.heading, level: entry.level, bytes: Buffer.byteLength(body, "utf8"), blurb: sectionBlurb(body) };
169
186
  });
170
187
  const bytes = Buffer.byteLength(document.content, "utf8");
171
188
  // A topic whose whole body sits under a single H1 (the title) would produce a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@stalfh233/omc-fake-platform": "0.5.7",
55
+ "@stalfh233/omc-fake-platform": "0.5.9",
56
56
  "commander": "14.0.3",
57
- "omc-core": "0.5.7",
57
+ "omc-core": "0.5.9",
58
58
  "proper-lockfile": "4.1.2",
59
59
  "write-file-atomic": "6.0.0",
60
60
  "zod": "^3.23.8",
@@ -68,7 +68,7 @@ only the section for the capability you need —
68
68
  - **能做哪些事**:字典与字典记录全生命周期(键值型/树型、启停);作为选择字段的选项来源;字典分类。
69
69
  - **工具**:`dictionary.list`、`dictionary.type`、`dictionary.names`、`dictionary.get`、`dictionary.records`、`dictionary.save`、`dictionary.status`、`dictionary.usage`、`dictionary.parent`、`dictionary.classes`、`dictionary.class.save`、`dictionary.class.delete`
70
70
  - **recipe**:`omc describe dictionary.save --example dictionary-key-value`
71
- - **边界 / 陷阱**:选择字段有三种选项来源——`custom` / `dictionary` / `businessModel`;关联单选/多选支持 `searchFormula` 与 `conditions` JSON 筛选。
71
+ - **边界 / 陷阱**:选择字段有三种选项来源——`custom` / `dictionary` / `businessModel`;关联单选/多选支持 `searchFormula` 与 `conditions` JSON 筛选。**字典没有删除**——平台只有停用/启用(`dictionary.status`,target= dictionary|record);不要找 dictionary.delete(分类有 `class.delete`,字典本体没有),清理测试残留用停用即可。
72
72
 
73
73
  ## 业务模型 `model`
74
74
 
@@ -12,7 +12,7 @@ be treated as a second source. Read this, then follow the topics it names.
12
12
 
13
13
  ## 最小路径(先读这段就能开始)
14
14
 
15
- 0. **调用与检索(跨平台)**:`omc call ... --args '<json>'` 的内联 JSON 在 Windows PowerShell 下因引号转义不可靠——**PS 环境优先 `--args-file <path>`**(把 JSON 写临时文件再引用;解析失败的报错里也有此提示)。读命令输出默认把中文转义为 `\uXXXX`(防乱码)——**对输出做内容检索前先加 `--utf8`(单次明文)或先解 JSON,"grep 搜不到中文"≠"没有配置"**(静默假阴性坑)。**stdout 恒为纯 JSON 信封,人读进度(「已执行 xxx」等)走 stderr**——脚本解析别 `2>&1` 合流(r33/r34 两轮探针同坑),合流后从首个 `{` 截断。
15
+ 0. **调用与检索(跨平台)**:`omc call ... --args '<json>'` 的内联 JSON 在 Windows PowerShell 下因引号转义不可靠——**PS 环境优先 `--args-file <path>`**(把 JSON 写临时文件再引用;解析失败的报错里也有此提示)。读命令输出默认把中文转义为 `\uXXXX`(防乱码)——**对输出做内容检索前先加 `--utf8`(单次明文)或先解 JSON,"grep 搜不到中文"≠"没有配置"**(静默假阴性坑)。stdout 恒为纯 JSON、人读进度在 stderr——脚本解析勿 `2>&1` 合流。
16
16
 
17
17
  0. **拼装器优先(高频写面一条命令)**:整模型建模(模型+N 字段+子表+发布+表单)`omc model-scaffold`;**存量模型加字段(建→发布→重建表单→列表加列)`omc field-add`**;字段计算 `omc compute`;条件→动作 `omc rule-when`;线性审批流 `omc approval-flow`;表单默认值 `omc form-default`;存量盘点 `data.list all:true`——命中这些形态先看 `--help`,比 recipe 手拼省 10+ 轮。
18
18
  0c. **清理一个模型用 `omc model-purge --code <x> [--purge-instances] --apply`**——按依赖倒序(实例 → 模型级联带走流程/视图/字段/表单),**不要手工逐层删**:删部门/字段留下缓存或孤儿,会让后续调用出现「假 40009 / 假孤儿 / 全局待办 50000」这类幽灵故障(r12–r14 三次实测)。若返回 `unremovableInstances`(业务对象已删的停滞实例,平台 `delete_instance` NPE),按返回的 `maintainerAction` 让维护者清 `biz_workflow_instance`;**不要改用 model.delete 硬来**。
@@ -189,16 +189,24 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
189
189
  ## 审批身份与角色作用域(r30 真机 2026-10-31)
190
190
 
191
191
  - `workitem.list` 只回**当前登录人**的待办:审批人配别人 → 看不到待办,`data.submit` 被 `submit-workitem-not-mine` 拒(40005)。演示闭环要么节点参与者用 `{Originator}`,要么把登录人加进审批角色。
192
- - **`org.role.addUsers` 必须紧跟 `org.role.setUserScope`**:只加成员不布作用域,平台不报错(仅 `org-role-member-scope-empty` 警告),但成员**无法被流程解析为参与人**。一对调用别做半。
193
- - **`transferDepartment` 迁人不迁作用域**(r33 真机):用户换部门后 `role.userScope` 仍指旧部门——成员随人走、作用域不随人走;迁移后按业务复核各成员作用域。
194
- - **`user.delete` 是软删**(r33 真机):回 `deleted:true` 但 `user.info` 仍 `exists:true`(`userStatus:DISABLE`)、部门树也可能仍见节点——验证口径用「列表/状态」,别声称物理清除。
192
+ - **`org.role.addUsers` 必须紧跟 `setUserScope`**:不布作用域仅警告但成员无法被流程解析为参与人。一对调用别做半。
193
+ - **`transferDepartment` 迁人不迁作用域**(r33 真机):换部门后 `role.userScope` 仍指旧部门——迁移后复核成员作用域。
194
+ - **`user.delete` 是软删**(r33 真机):回 `deleted:true` 但 `user.info` 仍 `exists:true`(DISABLE)——验证口径用列表/状态,别称物理清除。
195
195
  - **「名称重复 200001」≠ 失败**(r33 真机):多为首次已建成、重发才撞重复——先读回确认首建结果再决定。
196
+ - **删了的东西≠删干净(孤儿三连,1101 三例)**:①删模型留收藏+h_biz_* 行→日志刷 `appFunction not exist`;②规则死服务 A1→submit 500015;③流程模板删后绑定残留(h_workflow_relative_object/header 指死 code)→getWorkflow 50000。都是删上游留下游引用、报错在别处——先查孤儿引用,维护者按表清。
197
+ - **两套条件 DSL(r38/r39)**:listview.configure `conditions`={propertyCode, filterType, **defaultValue**}(值键叫 defaultValue);data.list `filter`={op, field, value}。互抄必错——形状各查 `--param conditions`/`--param filter`。
198
+ - **表单读回以控件清单为准,不看布尔位(r40)**:form.get 的 `published` 布尔会 false→true 抖动;draftViewJson 可含已删字段悬空引用(rebuild 自清)。判定布局依据=`publishedControlCodes` 内容;configure_form 回执已点名 `missingFieldCodes`。
199
+ - **submit 被拒仍可能已落草稿(r40)**:`data.save --submit:true` 遇许可证/权限拒绝时平台可能在拒答前已写草稿——拒后 data.list 对账 total,残留 data.delete 单清。
200
+ - **空聚合≠模型存在(r40)**:rule.list/datarule.list 对不存在模型静默回空——「0 条规则」≠模型在(mcc_contract 从不存在却一路答空)。rule.list 已修(空时探真身标 `modelPresent:false`);datarule.list 同陷阱——判存在性一律 model.inspect。
201
+ - **form.publish(rebuild) 后立即 submit 可能丢字段(r41 疑似竞态)**:链内演示行 `order_no` 落 null,同形状延迟复现正常。落演示行后 `data.load` 抽验非选项字段,缺值 data.save 单补,不重跑整链。
202
+ - **data.list 选项列投影可为 null(r40/r41)**:`all:true`/queryCode 下 RADIO 读 null,`data.load`/`filter` 才有值——验证选项字段以 data.load 为权威。
203
+ - **读回形状≠可写形状**(r38):`listview.get` 读回条件带 `null` 字段,原样回写被拒——读回是输出形态,可写面有自己的契约;null 归一(false/省略)或只回写要改的键。
204
+ - **data.list 的 columns 收窄可能静默空**(r38):非视图列字段投影 null;甚至 columns+分页 total>0 而 items 0——判"无数据"先去 columns 复查或用 data.load。
196
205
 
197
206
  ## workflow.update 的行级替换语义(r32 真机 2026-10-31)
198
207
 
199
208
  - **`btnConfigList` 局部行=整行替换**:只传 `{code, remark}` 会把该按钮的 `enabled` 静默置 `false`、`commentPermission` 置 `null`("驳回意见必填"丢失),同提交未提及的其他属性也被重置。改前先 `workflow.get_published`/`get_draft` 取整行,改目标属性后**整条回写**;`propertyPermissions` 同粒度。
200
- - 只改 `participant`/`btnConfigList` 的 activity 行**必须显式带 `type`**——省略 type 的豁免仅限带 `propertyPermissions` 的权限编辑行(拒绝信封已注明)。
201
- - **`SINGLE_PARTICIPANT` 配多角色只取第一个解析出的成员**(`MULTI_PARTICIPANT` 则全员会签)——演示/测试时"配置没生效"常是它;回读确认实际解析人。
209
+ - **`SINGLE_PARTICIPANT` 多角色只取第一个解析成员**(MULTI 则全员会签)——"配置没生效"常是它,回读确认解析人。
202
210
 
203
211
  ## 跨表回写的真实语义(1.1.25 源码 + 真机 2026-10-20)
204
212