@stalfh233/omc-cli 0.6.11 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/coverage/m1-coverage-manifest-v1.json +2 -0
  2. package/dist/approval-flow.js +84 -3
  3. package/dist/batch-call.js +32 -2
  4. package/dist/bench/call-face-eval.js +2 -2
  5. package/dist/call.js +35 -2
  6. package/dist/capability-overview.js +21 -7
  7. package/dist/cli-contract.js +34 -4
  8. package/dist/cli-output.js +6 -2
  9. package/dist/cli.js +303 -69
  10. package/dist/describe-cache.js +8 -4
  11. package/dist/environment-policy.js +20 -0
  12. package/dist/field-families.js +62 -4
  13. package/dist/form-support.js +293 -6
  14. package/dist/goal-routes.js +137 -22
  15. package/dist/identity.js +63 -11
  16. package/dist/lanes/app-contract.js +4 -4
  17. package/dist/lanes/app.js +19 -1
  18. package/dist/lanes/dictionary-contract.js +16 -5
  19. package/dist/lanes/dictionary-coverage.js +1 -0
  20. package/dist/lanes/dictionary.js +200 -8
  21. package/dist/lanes/environment-contract.js +18 -6
  22. package/dist/lanes/environment.js +129 -16
  23. package/dist/lanes/field-contract.js +2 -1
  24. package/dist/lanes/field.js +707 -59
  25. package/dist/lanes/form.js +52 -6
  26. package/dist/lanes/list-view-contract.js +2 -2
  27. package/dist/lanes/list-view.js +98 -2
  28. package/dist/lanes/meta.js +36 -10
  29. package/dist/lanes/model-contract.js +7 -6
  30. package/dist/lanes/model.js +154 -10
  31. package/dist/lanes/organization.js +67 -7
  32. package/dist/lanes/runtime-data-contract.js +4 -2
  33. package/dist/lanes/runtime-data.js +395 -41
  34. package/dist/lanes/workflow-contract.js +2 -2
  35. package/dist/lanes/workflow.js +381 -11
  36. package/dist/model-scaffold.js +11 -3
  37. package/dist/path-projection.js +192 -32
  38. package/dist/read-shapes.js +31 -11
  39. package/dist/recipes/field.js +16 -3
  40. package/dist/recipes/form.js +2 -1
  41. package/dist/recipes/runtime-data.js +58 -8
  42. package/dist/recipes/workflow.js +86 -1
  43. package/dist/refusal-guidance.js +18 -6
  44. package/dist/rollback-spec.js +41 -2
  45. package/dist/rollback.js +49 -11
  46. package/dist/script.js +72 -12
  47. package/dist/skills.js +91 -3
  48. package/dist/tools.js +113 -1
  49. package/dist/wire.js +13 -1
  50. package/dist/write-lock.js +92 -4
  51. package/dist/write.js +129 -16
  52. package/package.json +5 -3
  53. package/skills/omc-business-configuration.md +1 -1
  54. package/skills/omc-capabilities.md +5 -4
  55. package/skills/omc-playbook.md +41 -0
  56. package/skills/omc-refusals.md +58 -6
  57. package/skills/omc-tool-selection.md +2 -0
  58. package/skills/omc-write-hazards.md +3 -2
@@ -92,6 +92,91 @@ const workflowConditionalBranch = Object.freeze({
92
92
  requiresEnvironmentDerivedIds: true,
93
93
  prerequisiteData: { source: "workflow.get_draft", fields: ["activities[].activityCode", "org.role code"] },
94
94
  });
95
+ /**
96
+ * r31c F24: two-level / one-level fork-then-join. The single-layer
97
+ * `workflow-conditional-branch` recipe only shows A2→A3(cond) / A2→END(else).
98
+ * Agents that extend it to "金额>X 走两级、否则一级" often add a second
99
+ * conditional edge on the same pre/post pair (401013) or leave A_mgr with no
100
+ * else outbound (dead-end graph that validate still marks valid:true — F23).
101
+ *
102
+ * Shape (amount > 50000 → mgr → gm → END; else mgr → END):
103
+ * START → 填写 → 主管审批 ─┬─(GT 50000)→ 总经理审批 → END
104
+ * └─(else)───────────────→ END
105
+ */
106
+ const workflowConditionalTwoLevel = Object.freeze({
107
+ id: "workflow-conditional-two-level-approval",
108
+ title: "金额>阈值走两级审批,否则一级(分叉再汇合)",
109
+ status: "requires-prior-readback",
110
+ aliases: ["两级审批", "条件两级", "金额大于", "一级或两级", "分叉再汇合"],
111
+ tool: "workflow.update",
112
+ args: {
113
+ environment,
114
+ schemaCode: model,
115
+ code: "purchaseWorkflow",
116
+ template: {
117
+ workflowCode: "purchaseWorkflow",
118
+ activities: [
119
+ { activityCode: "Activity1", activityType: "START", activityName: "开始", i18nMessage: "开始" },
120
+ { activityCode: "Activity2", activityType: "PARTICIPANT", activityName: "填写申请单", i18nMessage: "填写申请单", participant: "{Originator}" },
121
+ {
122
+ activityCode: "A_mgr",
123
+ activityType: "PARTICIPANT",
124
+ activityName: "部门主管审批",
125
+ i18nMessage: "部门主管审批",
126
+ // Correct object shape (r31c F20): code/corpId/id/name/type — NOT participantType/roleId.
127
+ participant: JSON.stringify([{ code: "SYS_1000000", corpId: "other", id: "<roleId-from-org.role.list>", name: "主管", type: 2 }]),
128
+ },
129
+ {
130
+ activityCode: "A_gm",
131
+ activityType: "PARTICIPANT",
132
+ activityName: "总经理审批",
133
+ i18nMessage: "总经理审批",
134
+ participant: JSON.stringify([{ code: "<gm-role-code>", corpId: "other", id: "<gm-roleId>", name: "总经理", type: 2 }]),
135
+ },
136
+ { activityCode: "Activity5", activityType: "END", activityName: "结束", i18nMessage: "结束" },
137
+ ],
138
+ routes: [
139
+ { preNode: "Activity1", postNode: "Activity2" },
140
+ { preNode: "Activity2", postNode: "A_mgr" },
141
+ // Fork at A_mgr: high amount → second level; else → END. Same pre/post
142
+ // pair must NOT carry both an unconditional edge and a condition edge
143
+ // (platform 401013). Else uses utilizeElse:true on a DIFFERENT postNode.
144
+ { preNode: "A_mgr", postNode: "A_gm", ruleConditionGroup: [[{ currentDataItem: "totalAmount", bizPropertyType: 2, conditionType: "GT", value: "50000" }]] },
145
+ { preNode: "A_mgr", postNode: "Activity5", utilizeElse: true },
146
+ { preNode: "A_gm", postNode: "Activity5" },
147
+ ],
148
+ },
149
+ },
150
+ preconditions: [
151
+ "workflow.create 已完成;org.role.list 取真实角色 code/id 填 participant",
152
+ "数值字段已发布;bizPropertyType 用数字索引(NUMERICAL=2)",
153
+ "**整图替换**:省略 merge,否则旧 PARTICIPANT 节点会留下",
154
+ ],
155
+ conditionalRequirements: [
156
+ "**participant 对象数组只认 `{code,corpId,id,name,type}`**(type 2=角色 3=用户)。`participantType`/`roleId`/`participantName` 会被平台静默丢掉,读回变成 `[{}]`,validate 仍 valid、publish 才 warning——待办派不出去(r31c F20)。",
157
+ "**同一对 pre/post 不能既有无条件边又有条件边**(平台 401013「节点名A&节点名B」)。条件边与 else 边的 postNode 必须不同。",
158
+ "**有条件边的节点必须有 else 兜底**,否则条件不满足时流程断头;`workflow.validate` 会以 `reachabilityWarnings` 点名这类节点(warning,不阻断发布)。",
159
+ "汇合边(A_gm→END)是无条件边;不要再给 A_mgr→A_gm 加一条无条件边。",
160
+ "线性无分支请用 `omc approval-flow`;本配方只覆盖条件两级/一级。",
161
+ ],
162
+ priorToolReferences: [
163
+ { tool: "workflow.get_draft", args: { environment, workflowCode: "purchaseWorkflow" }, purpose: "取草稿基线;默认面含 activityCode/edges" },
164
+ { tool: "org.role.list", args: { environment }, purpose: "取角色 code/id 填 participant 对象(不要用自由文本名)" },
165
+ ],
166
+ dryRun: { supported: true, command: `omc call workflow.update --env ${environment} --dry-run --args '<recipe.args>'`, notes: "dry-run 校验 activities/routes/条件叶;不写入" },
167
+ expectedReadback: {
168
+ readback: { draftUpdated: true, rules: 2 },
169
+ next: ["workflow.validate", "workflow.publish", "data.submit 两条(>50000 / ≤50000)验 workitem.list"],
170
+ },
171
+ refusalRecovery: [
172
+ { code: "401013", action: "同一 pre/post 对重复连线或「条件边+无条件边」冲突——拆成不同 postNode,else 用 utilizeElse:true" },
173
+ { code: "workflow-route-condition-invalid", action: "条件叶只留 currentDataItem/bizPropertyType/conditionType/value" },
174
+ { code: "workflow-not-found", action: "先 workflow.create,再 get_draft 取基线" },
175
+ ...commonRecovery,
176
+ ],
177
+ requiresEnvironmentDerivedIds: true,
178
+ prerequisiteData: { source: "workflow.get_draft + org.role.list", fields: ["activities[].activityCode", "roles[].code", "roles[].id"] },
179
+ });
95
180
  const workflowBinding = Object.freeze({
96
181
  id: "workflow-handler-binding",
97
182
  title: "将规则合并绑定到 workflow handler 并发布",
@@ -116,7 +201,7 @@ const workflowBinding = Object.freeze({
116
201
  prerequisiteData: { source: "workflow.get_draft + rule.list", fields: ["workflow.code", "rule.code"] },
117
202
  });
118
203
  export const WORKFLOW_METADATA = Object.freeze({
119
- "workflow.update": Object.freeze({ recipes: [workflowTemplateUpdate, workflowConditionalBranch], relatedTools: ["workflow.get_draft", "workflow.publish"] }),
204
+ "workflow.update": Object.freeze({ recipes: [workflowTemplateUpdate, workflowConditionalBranch, workflowConditionalTwoLevel], relatedTools: ["workflow.get_draft", "workflow.publish"] }),
120
205
  "workflow.bindRule": Object.freeze({ recipes: [workflowBinding], relatedTools: ["workflow.create", "workflow.get_draft", "workflow.get_published", "rule.list"] }),
121
206
  });
122
207
  //# sourceMappingURL=workflow.js.map
@@ -15,12 +15,18 @@ const SHAPE_GATES = Object.freeze({
15
15
  recipeCommand: { tool: "data.submit", recipe: "plain-data-submit" },
16
16
  doNot: "不要传毫秒数(如 1790000000000)——不报错但落库 NULL,读回才发现",
17
17
  },
18
+ "child-rows-missing-id": {
19
+ whatWentWrong: "更新已有记录时,子表行没带 id",
20
+ correctShape: "三合一:既有行带 id(改);追加行 {…, rowStatus:\"Added\"};删除行 {id, rowStatus:\"Deleted\"}(只给 id)。少传行不会删",
21
+ recipeCommand: { tool: "data.update", recipe: "child-rows-upsert" },
22
+ doNot: "不要用同一批无 id 子行重试 update——每次都会再插一遍(r17 实测 2 行变 8 行);也不要靠少传行删行",
23
+ },
24
+ // 旧名兼容(r29b F7 前叫 submit-…,data.update 也会抛)
18
25
  "submit-child-rows-missing-id": {
19
26
  whatWentWrong: "更新已有记录时,子表行没带 id",
20
- correctShape: "更新时子表行必须带平台 id(新增行才省略);无 id 的行会被当**新增**插入,不判重",
21
- command: undefined,
22
- recipeCommand: { tool: "data.update", recipe: "plain-data-submit" },
23
- doNot: "不要用同一批无 id 子行重试 update——每次都会再插一遍(r17 实测 2 行变 8 行)",
27
+ correctShape: "三合一:既有行带 id(改);追加行 {…, rowStatus:\"Added\"};删除行 {id, rowStatus:\"Deleted\"}(只给 id)。少传行不会删",
28
+ recipeCommand: { tool: "data.update", recipe: "child-rows-upsert" },
29
+ doNot: "不要用同一批无 id 子行重试 update——每次都会再插一遍(r17 实测 2 行变 8 行);也不要靠少传行删行",
24
30
  },
25
31
  "submit-approval-agree-misplaced": {
26
32
  whatWentWrong: "agree 放在了 approval 对象里,平台只读顶层 agree",
@@ -105,7 +111,9 @@ const SHAPE_GATES = Object.freeze({
105
111
  * retry command — a command that would fail again is worse than none.
106
112
  */
107
113
  const FIX_FIRST_GATES = Object.freeze({
108
- "workflow-start-permission-denied": "40009 有三个独立同码原因:① 发起人角色没挂该流程的发起权限(ouScope ∩ 发起人部门链,全空会静默回退发起人);② 流程未启用/未发布;③ 模型未绑定流程。先 omc call workflow.get_published 核对 ②,再查角色 ouScope——不要反复换参数重试 data.submit",
114
+ "workflow-start-permission-denied": "40009 有三个独立同码原因:① 发起人角色没挂该流程的发起权限(ouScope ∩ 发起人部门链,全空会静默回退发起人);② 流程未启用/未发布;③ 模型未绑定流程。先 omc call workflow.permissions / workflow.get_published 核对;管理员发起成功带 bypassReason 时不证明白名单生效——换非管理员环境验证。不要反复换参数重试 data.submit",
115
+ "workflow-bound-to-listview": "303024 列表绑定了流程:直接粘贴回执 nextCommand(listview.configure:actions:[] + replaceActions:true + 现成 fieldCodes)解绑,再 workflow.delete;整体清理用 model-purge",
116
+ "workflow-start-permission-empty": "发起权限表为空:除平台管理员外无人能发起。用 workflow.addPermission 授给部门/角色/用户;管理员 data.submit 成功不证明白名单生效",
109
117
  "environment-write-disabled": "当前环境 profile 不可写:换可写环境,或让维护者在配置里开 writable——CLI 侧没有绕过开关",
110
118
  "production-confirmation-required": "生产环境的写需要显式 confirmProduction:true——确认这次写入真的要落生产再加该参数,不要靠重试",
111
119
  "app-not-published": "目标应用未发布:先 workflow/form/field 各面发布(或 app 级发布),再重试运行面操作",
@@ -191,7 +199,11 @@ export function refusalRecovery(blocker, payload, options = {}) {
191
199
  // message/detail, so both refused and frozen (uncertain) envelopes teach it.
192
200
  if (mentionsDashboardChannel(payload))
193
201
  return dashboardBoundaryGuidance();
194
- const shape = SHAPE_GATES[blocker];
202
+ // r29b F7: blockerAliases 让旧名也能命中新引导。
203
+ const aliasBlockers = Array.isArray(payload.blockerAliases)
204
+ ? payload.blockerAliases.filter((entry) => typeof entry === "string")
205
+ : [];
206
+ const shape = SHAPE_GATES[blocker] ?? aliasBlockers.map((alias) => SHAPE_GATES[alias]).find((entry) => entry !== undefined);
195
207
  if (shape) {
196
208
  const command = resolveCommand(shape, options.registry);
197
209
  const { recipeCommand: _drop, ...rest } = shape;
@@ -1,7 +1,21 @@
1
+ /** Business-key extractors for array alignment in deepDiff (r30a F14/F15). */
2
+ const ARRAY_ALIGN_KEYS = ["code", "id", "propertyCode", "activityCode", "ruleCode", "nodeCode"];
3
+ function arrayEntryKey(entry) {
4
+ if (entry === null || typeof entry !== "object")
5
+ return undefined;
6
+ const record = entry;
7
+ for (const key of ARRAY_ALIGN_KEYS) {
8
+ const value = record[key];
9
+ if (value !== undefined && value !== null && String(value) !== "")
10
+ return `${key}:${String(value)}`;
11
+ }
12
+ return undefined;
13
+ }
1
14
  /**
2
15
  * Field-level deep diff for the plan surface. Returns at most `limit`
3
- * changed paths plus the total count; arrays are compared by index
4
- * (length change is reported on the array path itself).
16
+ * changed paths plus the total count. Arrays of objects with a business key
17
+ * (`code`/`id`/…) are aligned by that key; index-only comparison is the
18
+ * fallback for scalars / unkeyed rows (r30a F14).
5
19
  */
6
20
  export function deepDiff(before, current, path = "", limit = 40, acc = []) {
7
21
  const walk = (b, c, p) => {
@@ -33,6 +47,31 @@ export function deepDiff(before, current, path = "", limit = 40, acc = []) {
33
47
  const cA = c;
34
48
  if (bA.length !== cA.length)
35
49
  acc.push({ path: p, before: `<array×${bA.length}>`, current: `<array×${cA.length}>` });
50
+ const bKeys = bA.map(arrayEntryKey);
51
+ const cKeys = cA.map(arrayEntryKey);
52
+ const canAlign = bKeys.every((key) => key !== undefined) && cKeys.every((key) => key !== undefined)
53
+ && new Set(bKeys).size === bKeys.length && new Set(cKeys).size === cKeys.length;
54
+ if (canAlign) {
55
+ const bMap = new Map(bKeys.map((key, index) => [key, bA[index]]));
56
+ const cMap = new Map(cKeys.map((key, index) => [key, cA[index]]));
57
+ for (const key of new Set([...bMap.keys(), ...cMap.keys()])) {
58
+ if (acc.length >= limit)
59
+ return;
60
+ const inB = bMap.has(key);
61
+ const inC = cMap.has(key);
62
+ const short = key.includes(":") ? key.slice(key.indexOf(":") + 1) : key;
63
+ if (!inB) {
64
+ acc.push({ path: `${p}[+${short}]`, before: "<absent>", current: cMap.get(key) });
65
+ continue;
66
+ }
67
+ if (!inC) {
68
+ acc.push({ path: `${p}[-${short}]`, before: bMap.get(key), current: "<absent>" });
69
+ continue;
70
+ }
71
+ walk(bMap.get(key), cMap.get(key), `${p}[${short}]`);
72
+ }
73
+ return;
74
+ }
36
75
  const shared = Math.min(bA.length, cA.length);
37
76
  for (let i = 0; i < shared && acc.length < limit; i += 1)
38
77
  walk(bA[i], cA[i], `${p}[${i}]`);
package/dist/rollback.js CHANGED
@@ -43,8 +43,19 @@ export async function listRollback(options, context) {
43
43
  entries = entries.filter((entry) => entry.target.includes(options.target));
44
44
  if (options.tool)
45
45
  entries = entries.filter((entry) => entry.tool === options.tool || entry.tool.startsWith(`${options.tool}.`));
46
+ // r30a F22: hide documented no-ops from the default timeline.
47
+ const actionable = entries.filter((entry) => entry.noop !== true);
46
48
  const limit = options.limit ? Number(options.limit) : 40;
47
- const timeline = [...entries].reverse().slice(0, Number.isSafeInteger(limit) && limit > 0 ? limit : 40);
49
+ const timeline = [...actionable].reverse().slice(0, Number.isSafeInteger(limit) && limit > 0 ? limit : 40);
50
+ // r30a F9/F11/F20: surface which writes in this window cannot be undone.
51
+ const unrollbackable = timeline
52
+ .filter((entry) => entry.rollbackable === false)
53
+ .map((entry) => ({
54
+ seq: entry.seq,
55
+ tool: entry.tool,
56
+ target: entry.target,
57
+ reason: entry.note ?? "rollbackable:false",
58
+ }));
48
59
  return {
49
60
  status: "ok",
50
61
  environment,
@@ -57,9 +68,11 @@ export async function listRollback(options, context) {
57
68
  target: entry.target,
58
69
  rollbackable: entry.rollbackable,
59
70
  ...(entry.note ? { note: entry.note } : {}),
71
+ ...(entry.steps !== undefined ? { steps: entry.steps } : {}),
60
72
  artifact: basename(entry.artifactFile),
61
73
  })),
62
- hint: "omc rollback plan <seq> 预览恢复(不写);omc rollback apply <seq> 执行恢复",
74
+ ...(unrollbackable.length > 0 ? { unrollbackable } : {}),
75
+ hint: "omc rollback plan <seq> 预览恢复(不写);omc rollback apply <seq> 执行恢复;unrollbackable[] 是本窗口内撤不了的写",
63
76
  };
64
77
  }
65
78
  async function buildPlan(ref, environmentOption, context) {
@@ -68,13 +81,20 @@ async function buildPlan(ref, environmentOption, context) {
68
81
  const entry = resolveJournalEntry(environment, ref, ctx.artifactsDir);
69
82
  if (!entry)
70
83
  throw new Error(`journal(${environment})中找不到 ${ref}:先 omc rollback list 核对 seq`);
84
+ // r30a F9/F12: compound writes are journaled with rollbackable:false and may
85
+ // have NO RollbackSpec on the tool (configure_model). Do not call findSpec
86
+ // for the unrollbackable short-circuit — that used to throw and become exit 15.
71
87
  const unrollbackable = (reason) => ({
72
88
  entry,
73
- spec: findSpec(entry.tool).spec,
89
+ spec: {
90
+ target: () => entry.target,
91
+ read: async () => null,
92
+ inverse: () => ({ kind: "irreversible", reason }),
93
+ },
74
94
  before: undefined,
75
95
  artifactArgs: {},
76
96
  context: ctx,
77
- plan: { environmentMatch: true, diff: { changes: [], total: 0 }, laterWrites: [], inverse: { kind: "irreversible", reason } },
97
+ plan: { environmentMatch: true, diff: { changes: [], total: 0 }, laterWrites: laterWritesOnTarget(environment, ctx.artifactsDir, entry.seq, entry.target), inverse: { kind: "irreversible", reason } },
78
98
  });
79
99
  if (!entry.rollbackable)
80
100
  return unrollbackable(entry.note ?? "该写的 before 快照捕获失败,无法回滚");
@@ -122,13 +142,17 @@ export async function planRollback(ref, options, context) {
122
142
  };
123
143
  const script = plan.inverse.kind === "calls" ? plan.inverse.calls.map((call) => JSON.stringify({ tool: call.tool, args: { ...call.args, environment: entry.environment } })) : [];
124
144
  const scriptOmitted = script.join("").length > 6000;
145
+ const irreversible = plan.inverse.kind === "irreversible";
125
146
  return {
126
147
  status: "ok",
127
148
  rollbackOf: { seq: entry.seq, tool: entry.tool, target: entry.target, ts: entry.ts },
128
149
  diff: { changedPaths: plan.diff.changes.map((change) => ({ path: change.path, before: preview(change.before), current: preview(change.current) })), total: plan.diff.total },
129
- ...(plan.laterWrites.length > 0
150
+ // r30a F12: do not claim "will overwrite later writes" when inverse is irreversible.
151
+ ...(plan.laterWrites.length > 0 && !irreversible
130
152
  ? { warning: `该目标在此写之后还有 ${plan.laterWrites.length} 次写(seq ${plan.laterWrites.map((w) => w.seq).join(", ")});本次回滚会用 before 参数覆盖这些后续变更`, laterWrites: plan.laterWrites.map((w) => ({ seq: w.seq, tool: w.tool, ts: w.ts })) }
131
- : {}),
153
+ : plan.laterWrites.length > 0
154
+ ? { laterWrites: plan.laterWrites.map((w) => ({ seq: w.seq, tool: w.tool, ts: w.ts })), laterWritesNote: "本写 irreversible,不会覆盖后续写;若要恢复后续变更请对那些 seq 分别 plan/apply" }
155
+ : {}),
132
156
  inverse: plan.inverse.kind === "calls"
133
157
  ? {
134
158
  kind: "calls",
@@ -139,19 +163,33 @@ export async function planRollback(ref, options, context) {
139
163
  : { script }),
140
164
  }
141
165
  : { kind: "irreversible", reason: plan.inverse.reason, ...(plan.inverse.partial ? { partial: plan.inverse.partial } : {}) },
142
- hint: "预览不写入;确认后 omc rollback apply <seq>(恢复走完整写闸门,回滚本身也会记 journal,可再回滚)",
166
+ hint: irreversible
167
+ ? `本写不可逆(apply 会 skipped/exit 0);${plan.laterWrites.length > 0 ? `下一步可试 omc rollback plan ${plan.laterWrites[0].seq} --env ${entry.environment}` : "无后续可恢复写"}`
168
+ : "预览不写入;确认后 omc rollback apply <seq>(恢复走完整写闸门,回滚本身也会记 journal,可再回滚)",
143
169
  };
144
170
  }
145
171
  export async function applyRollback(ref, options, context) {
146
172
  const loaded = await buildPlan(ref, options.environment, context);
147
173
  const { entry, plan, spec, artifactArgs, before, context: ctx } = loaded;
148
- if (plan.inverse.kind !== "calls" || plan.inverse.calls.length === 0)
174
+ if (plan.inverse.kind !== "calls" || plan.inverse.calls.length === 0) {
175
+ // r30a F12: irreversible is an expected answer → status:"skipped" + exit 0
176
+ // (was unclassified-status / exit 15). Keep reason + next step pasteable.
177
+ const inverse = plan.inverse;
178
+ const irreversible = inverse.kind === "irreversible";
179
+ const reason = irreversible ? inverse.reason : "无可执行恢复调用";
180
+ const later = plan.laterWrites;
149
181
  return {
150
- status: plan.inverse.kind === "irreversible" ? "irreversible" : "ok",
182
+ status: irreversible ? "skipped" : "ok",
183
+ applied: false,
184
+ reason,
151
185
  rollbackOf: { seq: entry.seq, tool: entry.tool, target: entry.target },
152
- inverse: plan.inverse,
153
- message: plan.inverse.kind === "irreversible" ? plan.inverse.reason : "无可执行恢复调用",
186
+ inverse,
187
+ message: reason,
188
+ ...(later.length > 0
189
+ ? { nextCommand: `omc rollback plan ${later[0].seq} --env ${entry.environment}`, hint: `本写不可逆;可对后续写 seq ${later.map((w) => w.seq).join(", ")} 再 plan/apply` }
190
+ : {}),
154
191
  };
192
+ }
155
193
  const results = [];
156
194
  for (const [index, call] of plan.inverse.calls.entries()) {
157
195
  const { exitCode, result } = await runToolCall(call.tool, {
package/dist/script.js CHANGED
@@ -5,44 +5,80 @@ import { SessionManager } from "./session-manager.js";
5
5
  import { makeToolContext } from "./tools.js";
6
6
  import { runToolCall } from "./call.js";
7
7
  import { currentUsageId, recordScriptCall } from "./usage-log.js";
8
+ function isCommentOrBlank(line) {
9
+ return line.length === 0 || line.startsWith("#") || line.startsWith("//");
10
+ }
11
+ /**
12
+ * Parse NDJSON. Blank / `#` / `//` comment lines are ignored. Other illegal
13
+ * lines are collected as `skipped` (r31b F16/F17) so a trailing `// total 32`
14
+ * no longer aborts the whole batch before any call runs.
15
+ */
8
16
  export function parseScript(text) {
9
17
  const lines = [];
18
+ const skipped = [];
10
19
  const raw = text.split("\n");
11
20
  for (const [index, untrimmed] of raw.entries()) {
12
21
  const line = untrimmed.trim();
13
- if (line.length === 0 || line.startsWith("#"))
22
+ if (isCommentOrBlank(line))
14
23
  continue;
15
24
  let parsed;
16
25
  try {
17
26
  parsed = JSON.parse(line);
18
27
  }
19
28
  catch (error) {
20
- throw new Error(`第 ${index + 1} 行不是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
29
+ skipped.push({
30
+ sourceLine: index + 1,
31
+ reason: `不是合法 JSON:${error instanceof Error ? error.message : String(error)}`,
32
+ text: line.slice(0, 120),
33
+ });
34
+ continue;
21
35
  }
22
36
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || typeof parsed.tool !== "string") {
23
- throw new Error(`第 ${index + 1} 行必须是 {"tool":"<名称>","args":{...},"env":"<环境>","dryRun":true} 形状(tool 必填)`);
37
+ skipped.push({
38
+ sourceLine: index + 1,
39
+ reason: `必须是 {"tool":"<名称>","args":{...}} 形状(tool 必填)`,
40
+ text: line.slice(0, 120),
41
+ });
42
+ continue;
24
43
  }
25
44
  const entry = parsed;
26
- if (entry.args !== undefined && (entry.args === null || typeof entry.args !== "object" || Array.isArray(entry.args)))
27
- throw new Error(`第 ${index + 1} 行 args 必须是 JSON 对象`);
45
+ if (entry.args !== undefined && (entry.args === null || typeof entry.args !== "object" || Array.isArray(entry.args))) {
46
+ skipped.push({
47
+ sourceLine: index + 1,
48
+ reason: "args 必须是 JSON 对象",
49
+ text: line.slice(0, 120),
50
+ });
51
+ continue;
52
+ }
28
53
  lines.push({
29
54
  tool: entry.tool,
30
55
  ...(typeof entry.args === "object" && entry.args !== null ? { args: entry.args } : {}),
31
56
  ...(typeof entry.env === "string" ? { env: entry.env } : {}),
32
57
  ...(entry.dryRun === true ? { dryRun: true } : {}),
58
+ sourceLine: index + 1,
33
59
  });
34
60
  }
35
- return lines;
61
+ return { lines, skipped };
36
62
  }
37
63
  export async function runScript(lines, options = {}) {
38
64
  const print = options.print ?? ((line) => console.log(line));
39
65
  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
66
  const parentId = currentUsageId();
67
+ const skipped = options.skipped ?? [];
68
+ for (const skip of skipped) {
69
+ print(JSON.stringify({
70
+ kind: "script-skipped",
71
+ sourceLine: skip.sourceLine,
72
+ reason: skip.reason,
73
+ text: skip.text,
74
+ note: "非法行已跳过(# 与 // 开头的注释/空行会被忽略;其它非法行计入 summary.skipped)",
75
+ }));
76
+ }
45
77
  let failed = false;
78
+ let ok = 0;
79
+ let failedCount = 0;
80
+ const failedIndexes = [];
81
+ let stoppedAt;
46
82
  for (const [index, line] of lines.entries()) {
47
83
  const startedMs = Date.now();
48
84
  const { exitCode, result } = await runToolCall(line.tool, {
@@ -67,12 +103,36 @@ export async function runScript(lines, options = {}) {
67
103
  print(rendered);
68
104
  if (exitCode !== 0) {
69
105
  failed = true;
106
+ failedCount += 1;
107
+ failedIndexes.push(index);
70
108
  if (options.continueOnError !== true) {
71
- print(JSON.stringify({ index, tool: line.tool, stopped: true, note: `第 ${index + 1} 行失败,后续 ${lines.length - index - 1} 行未执行;加 --continue 跑完全部` }));
72
- return exitCode;
109
+ stoppedAt = index;
110
+ print(JSON.stringify({
111
+ kind: "script-stopped",
112
+ stoppedAtIndex: index,
113
+ tool: line.tool,
114
+ remaining: lines.length - index - 1,
115
+ note: `第 ${index + 1} 行失败,后续 ${lines.length - index - 1} 行未执行;加 --continue 跑完全部(默认首败即停)`,
116
+ }));
117
+ break;
73
118
  }
74
119
  }
120
+ else {
121
+ ok += 1;
122
+ }
75
123
  }
124
+ print(JSON.stringify({
125
+ summary: {
126
+ total: lines.length,
127
+ ok,
128
+ failed: failedCount,
129
+ skipped: skipped.length,
130
+ ...(failedIndexes.length > 0 ? { failedIndexes } : {}),
131
+ ...(stoppedAt !== undefined ? { stoppedAt } : {}),
132
+ },
133
+ }));
134
+ if (lines.length === 0 && skipped.length > 0)
135
+ return 10; // invalid-input: nothing runnable
76
136
  return failed ? 1 : 0;
77
137
  }
78
138
  export function readScriptInput(path) {
package/dist/skills.js CHANGED
@@ -34,9 +34,78 @@ function skillFiles() {
34
34
  return [];
35
35
  }
36
36
  }
37
+ /** r29a F4:合成 topic——跨 app/folder/model/rule 的应用参数矩阵(不落盘 md,省预算)。 */
38
+ /** r31b F4:合成 topic——并发语义(真机实测结论,不落盘 md)。 */
39
+ const SYNTHETIC_SKILLS = Object.freeze({
40
+ "app-scope": {
41
+ name: "app-scope",
42
+ description: "跨 app/folder/model/rule 的应用范围参数名矩阵(主名/别名)",
43
+ content: [
44
+ "# 应用范围参数(app-scope)",
45
+ "",
46
+ "同一能力族里应用参数名不完全统一。一次看完,别逐个 `describe --full`。",
47
+ "",
48
+ "## 矩阵",
49
+ "",
50
+ "| 工具面 | 主参数 | 别名 | 备注 |",
51
+ "| --- | --- | --- | --- |",
52
+ "| `app.*` | (多数无;create/delete 用 `code`) | — | 管应用包本身 |",
53
+ "| `folder.*` | `applicationCode` | `appCode` | 双别名;冲突报 `app-code-conflict` |",
54
+ "| `model.*` | `applicationCode` | **不认** `appCode` | 传 `appCode` → `unknown-app-scope-arg` |",
55
+ "| `rule.*` | **无** | — | 按 `schemaCode` 定位;传应用键 → `tool-has-no-app-scope` |",
56
+ "| `report.*` | `appCode` | — | 报表面主用短名 |",
57
+ "",
58
+ "## 怎么查",
59
+ "",
60
+ "- 单能力:`omc capability model` / `folder`(回执带 `appScopeArgs`)",
61
+ "- 单工具:`omc describe <tool> --param applicationCode`",
62
+ "- 本 topic:`omc help app-scope`",
63
+ "",
64
+ "## 易踩坑",
65
+ "",
66
+ "1. `folder.tree` 能用 `appCode`,不能据此推断 `model.list` 也认。",
67
+ "2. `rule.list` 传 `applicationCode` 会被拒——规则面没有应用维度。",
68
+ "3. 不存在的 `applicationCode` 在 `model.list` 会 `app-not-found`(不是空列表)。",
69
+ "",
70
+ ].join("\n"),
71
+ },
72
+ concurrency: {
73
+ name: "concurrency",
74
+ description: "环境写锁 / 记录级 last-writer-wins / version 非乐观锁(r31 真机)",
75
+ content: [
76
+ "# 并发语义(r31 真机)",
77
+ "",
78
+ "写锁是**环境级**;记录级**无乐观锁**。CLI 不提供 `ifVersion`。以下全部双进程实测。",
79
+ "",
80
+ "| 操作 | 保护 | 结果 |",
81
+ "| --- | --- | --- |",
82
+ "| 并行写 | 环境写锁 | 输家 `write-lock-held`(`retryable`/`lockOwner`/`nextCommand`) |",
83
+ "| 同记录 `data.update` 不同字段 +`--wait` | 写锁串行+patch | 两字段都保住;**同字段**后写覆盖 |",
84
+ "| 读-改-写(旧值算新值) | **无** | 后写覆盖 |",
85
+ "| 行 `version` | 只读计数器 | update 成功 +1;`formData.version` 静默忽略(**不是乐观锁**) |",
86
+ "| 并行同 code 字段/模型 | 写锁+存在性闸 | 一成功;一 `duplicate-field`/`model-create-duplicate` |",
87
+ "| `data.list` 翻页 vs 写 | 读无快照 | 无 `snapshotAt`;可重复/漏行,`total` 可漂移 |",
88
+ "| 同 id `data.delete` | 写锁串行 | 第二次 `performed.skipped:\"already-absent\"` |",
89
+ "",
90
+ "`data.list` 唯一一致性键是 `filter.draftPolicy`(草稿是否计入)。",
91
+ "",
92
+ "读面数组键:`field.list`→`fields`;`model.list`→`models`;`data.list`→`items`(别用错 `--jq`)。",
93
+ "",
94
+ "```bash",
95
+ "(omc call data.save --env local --args '{\"schemaCode\":\"<m>\",\"formData\":{\"code\":\"a\",\"value\":1}}') &",
96
+ "(omc call data.save --env local --args '{\"schemaCode\":\"<m>\",\"formData\":{\"code\":\"b\",\"value\":2}}') & wait",
97
+ "```",
98
+ "",
99
+ "- 写串行或 `--wait`;**不要** shell 重试循环。",
100
+ "- 批量用 `omc script`(同进程更快);**测写锁必须多进程 `omc call`**。",
101
+ "- 详见 `omc help omc-write-hazards --section \"写调用纪律\"`。",
102
+ "",
103
+ ].join("\n"),
104
+ },
105
+ });
37
106
  /** Every methodology topic exposed by `omc help`. */
38
107
  export function listSkills() {
39
- return skillFiles().map((file) => {
108
+ const files = skillFiles().map((file) => {
40
109
  const name = file.replace(/\.md$/, "");
41
110
  let description = `OMC methodology: ${name}`;
42
111
  try {
@@ -47,6 +116,8 @@ export function listSkills() {
47
116
  }
48
117
  return { name, description };
49
118
  });
119
+ const synthetic = Object.values(SYNTHETIC_SKILLS).map(({ name, description }) => ({ name, description }));
120
+ return [...files, ...synthetic].sort((left, right) => left.name.localeCompare(right.name));
50
121
  }
51
122
  /**
52
123
  * r43 后 backlog:`omc help 编号` 曾零命中(高频中文业务词不是 topic 名)。
@@ -76,6 +147,11 @@ const HELP_TOPIC_SYNONYMS = Object.freeze({
76
147
  验收: ["omc-acceptance-criteria"],
77
148
  入门: ["omc-start-here"],
78
149
  拒绝: ["omc-refusals"],
150
+ 并发: ["concurrency", "omc-write-hazards"],
151
+ 竞争: ["concurrency"],
152
+ 乐观锁: ["concurrency"],
153
+ 写锁: ["concurrency", "omc-write-hazards"],
154
+ 快照: ["concurrency"],
79
155
  });
80
156
  export function suggestHelpTopics(query) {
81
157
  if (query === undefined || query.length === 0)
@@ -99,15 +175,25 @@ export function suggestHelpTopics(query) {
99
175
  }
100
176
  return [...hits].slice(0, 5);
101
177
  }
178
+ /** Appended to every `--all` body so the probe learns the cheap face (r31c F2). */
179
+ const HELP_ALL_SECTION_HINT = '\n\n> hint:"下次可按节读:omc help <topic> --section <标题>"\n';
102
180
  /** Read one methodology topic for `omc help <topic>`. */
103
181
  export function readSkill(name) {
104
182
  if (!/^[a-z0-9-]+$/.test(name))
105
183
  return undefined;
184
+ if (SYNTHETIC_SKILLS[name] !== undefined) {
185
+ const synthetic = SYNTHETIC_SKILLS[name];
186
+ return { ...synthetic, content: `${synthetic.content.trimEnd()}${HELP_ALL_SECTION_HINT}` };
187
+ }
106
188
  const entry = listSkills().find((skill) => skill.name === name);
107
189
  if (entry === undefined)
108
190
  return undefined;
109
191
  try {
110
- return { ...entry, content: normalizeLineEndings(readFileSync(join(skillsDir, `${name}.md`), "utf8")) };
192
+ const content = normalizeLineEndings(readFileSync(join(skillsDir, `${name}.md`), "utf8"));
193
+ // r31c F2: `--all` used to dump 5–14KB with no "use --section next time"
194
+ // hint (that tip lived only on the index face / cli.ts). Append one line so
195
+ // the expensive face teaches the cheap face. Index blurbs stay ≤40 chars.
196
+ return { ...entry, content: `${content.trimEnd()}${HELP_ALL_SECTION_HINT}` };
111
197
  }
112
198
  catch {
113
199
  return undefined;
@@ -210,7 +296,9 @@ export function readSkillSection(name, requested) {
210
296
  */
211
297
  /** First meaningful body line of a section: skip headings/blank/fence/list markers. */
212
298
  function sectionBlurb(body) {
213
- const limit = 70;
299
+ // r31c F2: index blurbs must stay ≤40 chars so a bare `omc help <topic>`
300
+ // is enough to pick the right section (probe had to open --all / second round).
301
+ const limit = 40;
214
302
  // sliceSectionText includes the section's own heading line — skip it.
215
303
  for (const raw of body.split(/\r?\n/).slice(1)) {
216
304
  const line = raw.trim();