@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
package/dist/tools.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { basename } from "node:path";
2
+ import { z } from "zod";
1
3
  import { loadCoverageManifest } from "./coverage.js";
2
4
  import { makeToolContext } from "./context.js";
3
5
  import { registerMetaLane } from "./lanes/meta.js";
@@ -23,9 +25,18 @@ import { registerReportLane } from "./lanes/report.js";
23
25
  import { registerApplyChangesLane } from "./lanes/apply-changes.js";
24
26
  import { executionMetadataFor } from "./execution-metadata.js";
25
27
  import { attachRefusalRecovery } from "./refusal-guidance.js";
26
- import { ToolRefusalError } from "./tool-types.js";
28
+ import { defineTool, ToolRefusalError } from "./tool-types.js";
29
+ import { environmentArg } from "./args.js";
30
+ import { appendJournalEntry } from "./journal.js";
31
+ import { listRequestArtifacts } from "./write.js";
27
32
  export { ToolRefusalError, ToolUncertainError } from "./tool-types.js";
28
33
  export { makeToolContext };
34
+ /** Compound package writes that need an honest non-rollbackable journal line (r30a F9/F11). */
35
+ const COMPOUND_JOURNAL_TOOLS = new Set(["configure_model", "configure_form"]);
36
+ const COMPOUND_IRREVERSIBLE = Object.freeze({
37
+ configure_model: "复合写无单一逆操作,只记 journal(rollbackable:false);清理请 model-purge / 逐项回滚子步骤",
38
+ configure_form: "复合写无单一逆操作,只记 journal(rollbackable:false);表单无删除端点,请用 form.draft 覆盖",
39
+ });
29
40
  /**
30
41
  * Composition root: assemble the governed tool surface from one adapter
31
42
  * module per configuration lane. The lane modules own their tool
@@ -58,11 +69,61 @@ export function buildToolRegistry() {
58
69
  // LAST: apply_changes looks up sibling write tools at call time, so it must
59
70
  // capture the fully-populated registry array.
60
71
  registerApplyChangesLane(tools);
72
+ // r30a F4: read-only requestId lookup over local write artifacts (no new store).
73
+ registerRequestStatusTool(tools);
61
74
  for (const tool of tools) {
62
75
  const metadata = executionMetadataFor(tool);
63
76
  if (metadata)
64
77
  tool.executionMetadata = metadata;
65
78
  }
79
+ // r30a F9/F11/F23: wrap compound package writes so they leave one honest
80
+ // journal line (rollbackable:false) + receipt.rollback.seq + irreversible hint.
81
+ for (const tool of tools) {
82
+ if (!COMPOUND_JOURNAL_TOOLS.has(tool.name))
83
+ continue;
84
+ const inner = tool.handler;
85
+ const toolName = tool.name;
86
+ tool.handler = async (ctx, args, self) => {
87
+ const result = await inner(ctx, args, self);
88
+ if (!result || typeof result !== "object")
89
+ return result;
90
+ const record = result;
91
+ if (record.status !== "ok" || record.deliveryState !== "DELIVERED")
92
+ return result;
93
+ if (record.rollback && typeof record.rollback === "object")
94
+ return result;
95
+ const environment = typeof args.environment === "string" && args.environment.length > 0
96
+ ? args.environment
97
+ : "local";
98
+ let environmentFingerprint = "compound-write";
99
+ try {
100
+ const session = await ctx.sessions.session(args.environment);
101
+ environmentFingerprint = session.environmentFingerprint();
102
+ }
103
+ catch { /* journal still useful without fingerprint */ }
104
+ const code = typeof args.code === "string" ? args.code : typeof args.schemaCode === "string" ? args.schemaCode : toolName;
105
+ const steps = Array.isArray(record.steps) ? record.steps : undefined;
106
+ const entry = appendJournalEntry(ctx.artifactsDir, {
107
+ v: 1,
108
+ environment,
109
+ environmentFingerprint,
110
+ tool: toolName,
111
+ target: `${toolName}:${code}`,
112
+ beforeSha: null,
113
+ rollbackable: false,
114
+ note: "复合写无逆操作,只记录(不假装可回滚)",
115
+ ...(steps !== undefined ? { steps: steps.map((step) => (step && typeof step === "object" ? { step: step.step, skipped: step.skipped } : step)) } : {}),
116
+ artifactFile: typeof record.detailFile === "string" ? record.detailFile : `${toolName}.compound.json`,
117
+ artifactId: typeof record.detailFile === "string" ? basename(record.detailFile) : `${toolName}-compound`,
118
+ artifactSha: typeof record.artifactFingerprint === "string" ? record.artifactFingerprint : typeof record.detailSha256 === "string" ? record.detailSha256 : "compound",
119
+ });
120
+ return {
121
+ ...record,
122
+ rollback: { seq: entry.seq, rollbackable: false, hint: `omc rollback plan ${entry.seq}(--env ${entry.environment})→ irreversible/skipped` },
123
+ irreversible: COMPOUND_IRREVERSIBLE[toolName] ?? "复合写无逆操作,只记 journal",
124
+ };
125
+ };
126
+ }
66
127
  // r19 ticket 06 — 拒绝即教学,收口在最深的一层:每个 handler 返回/抛出的
67
128
  // refused 结果在这里统一附加 recovery(同源配方命令、正确形状、不要做什么)。
68
129
  // 收口在 registry 而不是 CLI 出口,是因为 runToolCall、测试 harness、MCP
@@ -98,5 +159,56 @@ export function buildToolRegistry() {
98
159
  }
99
160
  return tools;
100
161
  }
162
+ /** Local-only requestId readback (r30a F4) — exposes findCompleted's store. */
163
+ function registerRequestStatusTool(tools) {
164
+ tools.push(defineTool({
165
+ name: "request.status",
166
+ layer: "meta",
167
+ kind: "read",
168
+ description: "按 requestId 查本机写产物;uncertain 后确认,不查远端。",
169
+ inputSchema: {
170
+ environment: environmentArg,
171
+ requestId: z.string().min(1),
172
+ tool: z.string().optional(),
173
+ },
174
+ handler: async (ctx, args) => {
175
+ const requestId = String(args.requestId);
176
+ const environment = typeof args.environment === "string" && args.environment.length > 0 ? args.environment : undefined;
177
+ const tool = typeof args.tool === "string" && args.tool.length > 0 ? args.tool : undefined;
178
+ const hits = listRequestArtifacts(ctx.artifactsDir, requestId, { ...(tool ? { tool } : {}), ...(environment ? { environment } : {}) });
179
+ if (hits.length === 0) {
180
+ return {
181
+ status: "ok",
182
+ found: false,
183
+ requestId,
184
+ ...(environment ? { environment } : {}),
185
+ hint: "本机无此 requestId;可按业务键 data.list/model.list 再确认",
186
+ };
187
+ }
188
+ const latest = hits[hits.length - 1];
189
+ const artifact = latest.artifact;
190
+ const performed = (artifact.performed ?? {});
191
+ return {
192
+ status: "ok",
193
+ found: true,
194
+ requestId,
195
+ tool: artifact.tool,
196
+ environment: artifact.environment,
197
+ deliveryState: artifact.deliveryState,
198
+ completedAt: artifact.recordedAt,
199
+ requestFingerprint: artifact.requestFingerprint,
200
+ result: {
201
+ ...(typeof performed.bizObjectId === "string" ? { bizObjectId: performed.bizObjectId } : {}),
202
+ ...(performed.skipped !== undefined ? { skipped: performed.skipped } : {}),
203
+ ...(artifact.readback !== undefined ? { readback: artifact.readback } : {}),
204
+ performed,
205
+ },
206
+ artifactFile: latest.file,
207
+ matches: hits.length,
208
+ ...(hits.length > 1 ? { hint: `同 id ${hits.length} 条;以上为最后一条` } : {}),
209
+ };
210
+ },
211
+ }));
212
+ }
101
213
  export { loadCoverageManifest };
102
214
  //# sourceMappingURL=tools.js.map
package/dist/wire.js CHANGED
@@ -235,7 +235,19 @@ export async function wireCall(session, call) {
235
235
  data: envelope.data ?? null,
236
236
  readbackFingerprint: "",
237
237
  };
238
- if (state)
238
+ // 2026-11-05(ticket-110 回归):**否定读不缓存**。
239
+ //
240
+ // `sheetGet` 对「还没有表单」的模型回 errcode 0 + data:null——这不是错误,
241
+ // 是被缓存的合法响应。于是同一会话里一旦有人读过「表单不存在」(例如
242
+ // `field.publish` 的 formBinding 咨询),后面 60s 内的所有 sheetGet 都命中
243
+ // 这份**空**缓存:设计器刚建好的表单 / 测试直接种进去的布局都读不到,
244
+ // `onlinejs.compose` 于是把既有 toolbar/template 当不存在,重建空骨架,
245
+ // 设计器写的控件属性(className 等)被永久覆盖。
246
+ //
247
+ // 负面结果(data 为 null / 空数组)恰恰是最容易在下一秒被写操作推翻的状态,
248
+ // 缓存它省不了往返,只会制造陈旧读。
249
+ const cacheableResult = result.data !== null && !(Array.isArray(result.data) && result.data.length === 0);
250
+ if (state && cacheableResult)
239
251
  state.entries.set(key, { result: structuredClone(result), query: call.query ?? {}, storedAt: clock() });
240
252
  return result;
241
253
  }
@@ -11,16 +11,72 @@ function lockTarget(environment) {
11
11
  function ownerFile(environment) {
12
12
  return `${lockTarget(environment)}.lock.owner.json`;
13
13
  }
14
+ /** Extract `data.save` from `… cli.js call data.save …`, or a top-level verb. */
15
+ export function reduceLockCommand(commandLine) {
16
+ const tokens = commandLine.trim().split(/\s+/).filter(Boolean);
17
+ const callAt = tokens.lastIndexOf("call");
18
+ if (callAt >= 0 && tokens[callAt + 1] && !tokens[callAt + 1].startsWith("-")) {
19
+ const tool = tokens[callAt + 1];
20
+ return { command: tool, tool };
21
+ }
22
+ const intentAt = tokens.lastIndexOf("intent");
23
+ if (intentAt >= 0 && tokens[intentAt + 1] && !tokens[intentAt + 1].startsWith("-")) {
24
+ return { command: `intent ${tokens[intentAt + 1]}` };
25
+ }
26
+ // Fall back to the last non-flag token that looks like a CLI verb / tool.
27
+ for (let i = tokens.length - 1; i >= 0; i -= 1) {
28
+ const token = tokens[i];
29
+ if (token.startsWith("-"))
30
+ continue;
31
+ if (token.includes("/") || token.endsWith(".js") || token === "node")
32
+ continue;
33
+ return { command: token };
34
+ }
35
+ return { command: commandLine };
36
+ }
37
+ function normalizeOwner(raw) {
38
+ if (!raw || typeof raw.pid !== "number")
39
+ return null;
40
+ const startedAt = typeof raw.startedAt === "string" ? raw.startedAt : "";
41
+ const legacyCommand = typeof raw.command === "string" ? raw.command : "";
42
+ const commandLine = typeof raw.commandLine === "string" && raw.commandLine.length > 0
43
+ ? raw.commandLine
44
+ : legacyCommand;
45
+ const reduced = typeof raw.tool === "string" && raw.tool.length > 0
46
+ ? { command: raw.tool, tool: raw.tool }
47
+ : reduceLockCommand(commandLine || legacyCommand);
48
+ // Prefer an already-reduced `command` when it is short (new writers); otherwise
49
+ // re-reduce from the command line so old ~110-char owners still shrink.
50
+ const command = typeof raw.command === "string" && raw.command.length > 0 && raw.command.length <= 64 && !raw.command.includes("/")
51
+ ? raw.command
52
+ : reduced.command;
53
+ return {
54
+ pid: raw.pid,
55
+ startedAt,
56
+ command,
57
+ commandLine: commandLine || command,
58
+ ...(reduced.tool !== undefined ? { tool: reduced.tool } : typeof raw.tool === "string" ? { tool: raw.tool } : {}),
59
+ };
60
+ }
14
61
  function readOwner(environment) {
15
62
  try {
16
- return JSON.parse(readFileSync(ownerFile(environment), "utf8"));
63
+ return normalizeOwner(JSON.parse(readFileSync(ownerFile(environment), "utf8")));
17
64
  }
18
65
  catch {
19
66
  return null;
20
67
  }
21
68
  }
22
69
  function writeOwner(environment) {
23
- const owner = { pid: process.pid, startedAt: new Date().toISOString(), command: process.argv.slice(0, 4).join(" ") };
70
+ // Persist the full argv so forensics keep the path; reduce for the `command` face.
71
+ const commandLine = process.argv.join(" ");
72
+ const reduced = reduceLockCommand(commandLine);
73
+ const owner = {
74
+ pid: process.pid,
75
+ startedAt: new Date().toISOString(),
76
+ command: reduced.command,
77
+ commandLine,
78
+ ...(reduced.tool !== undefined ? { tool: reduced.tool } : {}),
79
+ };
24
80
  try {
25
81
  writeFileSync(ownerFile(environment), JSON.stringify(owner, null, 2) + "\n", { mode: 0o600 });
26
82
  }
@@ -78,17 +134,23 @@ async function tryAcquire(environment) {
78
134
  }
79
135
  const lockDir = `${lockTarget(environment)}.lock`;
80
136
  const ownerDead = owner !== null && Number.isInteger(owner.pid) && !pidAlive(owner.pid);
137
+ const ownerLabel = owner
138
+ ? `owner pid=${owner.pid} started=${owner.startedAt} command=${owner.command}`
139
+ : "owner metadata missing (the holder predates lock metadata or crashed while acquiring)";
81
140
  return {
82
141
  held: false,
83
142
  reason: "conflict",
84
143
  owner,
85
144
  recovery: [
86
145
  `another process holds the write lock for environment ${environment}`,
87
- owner ? `owner pid=${owner.pid} started=${owner.startedAt} command=${owner.command}` : "owner metadata missing (the holder predates lock metadata or crashed while acquiring)",
146
+ ownerLabel,
88
147
  `lock path: ${lockDir}`,
89
148
  ...(ownerDead
90
149
  ? ["owner pid is NOT alive → this is a STALE lock; re-running the same command now recovers automatically (or delete the lock path above)"]
91
- : ["the holder is alive → wait for it to finish, then retry (retryable:true); do NOT delete the lock path while the holder runs"]),
150
+ : [
151
+ "the holder is alive → wait for it to finish, then retry (retryable:true); do NOT delete the lock path while the holder runs",
152
+ "不要写 shell 重试循环;用 --wait <ms> 让 CLI 内部排队(例:omc call <tool> --wait 30000)",
153
+ ]),
92
154
  `to force recovery when the owner is dead: rm -rf "${lockDir}" then retry`,
93
155
  ],
94
156
  };
@@ -106,4 +168,30 @@ export function staleLockPresent(environment) {
106
168
  const owner = readOwner(environment);
107
169
  return owner !== null && Number.isInteger(owner.pid) && !pidAlive(owner.pid);
108
170
  }
171
+ /** Structured face for a write-lock-held refusal (r31b F2). */
172
+ export function writeLockHeldFace(options) {
173
+ const wait = Math.max(1_000, options.waitMs && options.waitMs > 0 ? options.waitMs : 30_000);
174
+ const tool = options.refusedTool && options.refusedTool.length > 0 ? options.refusedTool : "<tool>";
175
+ const lockOwnerFace = options.owner
176
+ ? {
177
+ tool: options.owner.tool ?? options.owner.command,
178
+ pid: options.owner.pid,
179
+ startedAt: options.owner.startedAt,
180
+ command: options.owner.command,
181
+ commandLine: options.owner.commandLine,
182
+ }
183
+ : null;
184
+ return {
185
+ status: "refused",
186
+ blocker: "write-lock-held",
187
+ environment: options.environment,
188
+ retryable: true,
189
+ owner: options.owner,
190
+ lockOwner: lockOwnerFace,
191
+ nextCommand: `omc call ${tool} --env ${options.environment} --wait ${wait}`,
192
+ note: "不要写 shell 重试循环;用 --wait 排队",
193
+ message: `another process holds the write lock for environment ${options.environment}(retryable:true;用 --wait 排队,不要写 shell 重试循环)`,
194
+ recovery: options.recovery,
195
+ };
196
+ }
109
197
  //# sourceMappingURL=write-lock.js.map
package/dist/write.js CHANGED
@@ -50,7 +50,9 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
50
50
  // A face may supply a session-aware dry-run projection (e.g. model.delete's
51
51
  // reference scan) so the preview carries facts that only live reads can
52
52
  // establish — never a second write path.
53
- dryRunResult: dryRunPayload ? (plan, session) => dryRunPayload(session, args).then((payload) => ({ status: "dry-run", wouldChange: true, plan, idempotency: idempotencyDeclaration(tool.name), ...payload, ...(args.requestId ? { requestId: args.requestId } : {}) })) : (plan) => ({ status: "dry-run", wouldChange: true, plan, idempotency: idempotencyDeclaration(tool.name), ...(args.requestId ? { requestId: args.requestId } : {}) }),
53
+ // First-write / dry-run receipts use replay:"none" — the duplicate tag only
54
+ // appears on a true same-fingerprint replay (r30a F1).
55
+ dryRunResult: dryRunPayload ? (plan, session) => dryRunPayload(session, args).then((payload) => ({ status: "dry-run", wouldChange: true, plan, idempotency: idempotencyDeclaration(tool.name, "none"), ...payload, ...(args.requestId ? { requestId: args.requestId } : {}) })) : (plan) => ({ status: "dry-run", wouldChange: true, plan, idempotency: idempotencyDeclaration(tool.name, "none"), ...(args.requestId ? { requestId: args.requestId } : {}) }),
54
56
  replay: (plan) => (requestFingerprint ? findCompleted(ctx.artifactsDir, args.requestId, tool.name, plan.environment, requestFingerprint) : undefined),
55
57
  });
56
58
  if (outcome.kind === "settled")
@@ -105,6 +107,18 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
105
107
  message: `${error.message}——这是平台 ORM 的对象状态错误(Serializer.update/delete 要求实体先被装载),不是入参或规则图的形状问题。` +
106
108
  "同一份入参再保存一次通常即恢复。持续复现就换行:rule.delete 后重建(rule.save 会铸新记录),或用 rule.get 读回完整行再 rule.node.update 改一处——不要继续重试同一份 payload。",
107
109
  };
110
+ // r30a F10:errcode 40000「调用数据库发生异常」是平台侧故障,不是入参形状问题。
111
+ // 标 platform-fault,避免脚本把原样透出的业务码当成「改参数再试」。
112
+ if (error.errcode === 40000 || /调用数据库发生异常/.test(error.message))
113
+ return {
114
+ status: "refused",
115
+ blocker: `wire-${error.kind}`,
116
+ errcode: error.errcode,
117
+ "platform-fault": true,
118
+ message: `${error.message}——这是平台侧异常(errcode ${error.errcode}),不是入参形状问题;勿改参数硬重试,稍后重试或查平台日志。`,
119
+ nextAction: "稍后重试同一请求;持续复现请查平台 DB/ORM 日志,不要把 40000 当参数错误",
120
+ ...(error.diagnostic ? { diagnostic: error.diagnostic } : {}),
121
+ };
108
122
  return { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message, ...(error.diagnostic ? { diagnostic: error.diagnostic } : {}) };
109
123
  }
110
124
  throw error;
@@ -129,7 +143,7 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
129
143
  const artifactRef = writeArtifact(ctx, `${tool.name}-${randomUUID().slice(0, 8)}.json`, artifact);
130
144
  return { status: executed.status, steps: executed.steps, failedStep: executed.failedStep, ...executed.envelope, ...(args.requestId ? { requestId: args.requestId } : {}), artifactFingerprint: artifactRef.detailSha256 };
131
145
  }
132
- const { performed, readback, steps, halfSuccess } = executed;
146
+ const { performed, readback, steps, halfSuccess, irreversible } = executed;
133
147
  // Readback gate (live 2026-10-17): a tool that reports `readback.present:false`
134
148
  // has NOT proven its own write — the platform accepted the request but the
135
149
  // object is absent from the authoritative face. "Called without error" must
@@ -165,8 +179,13 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
165
179
  const artifactRef = writeArtifact(ctx, `${tool.name}-${randomUUID().slice(0, 8)}.json`, artifact);
166
180
  // Journal AFTER the artifact is durable: one append-only line per
167
181
  // delivered governed config write, feeding `omc rollback list/plan/apply`.
182
+ // r30a F22: already-absent / noop writes must NOT pollute the rollback
183
+ // timeline (reverting a no-op delete would try to "restore" something that
184
+ // never existed in this session).
185
+ const skipped = typeof performed.skipped === "string" ? performed.skipped : undefined;
186
+ const isNoopWrite = performed.noop === true || skipped === "already-absent" || skipped === "stale-record-cache";
168
187
  let rollbackRef;
169
- if (tool.rollback) {
188
+ if (tool.rollback && !isNoopWrite) {
170
189
  const entry = appendJournalEntry(ctx.artifactsDir, {
171
190
  v: 1,
172
191
  environment: session.environmentName,
@@ -182,7 +201,25 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
182
201
  });
183
202
  rollbackRef = { seq: entry.seq, hint: `omc rollback plan ${entry.seq}(--env ${entry.environment})` };
184
203
  }
185
- return { status: "ok", deliveryState, ...(readOnlyEscapeNote !== undefined ? { readOnlyEscape: readOnlyEscapeNote } : {}), performed, readback, ...(readbackWarning ? { readbackWarning } : {}), ...(steps !== undefined ? { steps } : {}), ...(halfSuccess !== undefined ? { halfSuccess } : {}), ...(args.requestId ? { requestId: args.requestId, idempotency: idempotencyDeclaration(tool.name) } : {}), ...(productionConfirmed ? { productionConfirmed: true } : {}), ...(rollbackRef ? { rollback: rollbackRef } : {}), summary: writeSummary(tool.name, args, { performed, readback }, "done"), detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256, artifactFingerprint: artifactRef.detailSha256 };
204
+ return {
205
+ status: "ok",
206
+ deliveryState,
207
+ ...(readOnlyEscapeNote !== undefined ? { readOnlyEscape: readOnlyEscapeNote } : {}),
208
+ performed,
209
+ readback,
210
+ ...(readbackWarning ? { readbackWarning } : {}),
211
+ ...(steps !== undefined ? { steps } : {}),
212
+ ...(halfSuccess !== undefined ? { halfSuccess } : {}),
213
+ ...(typeof irreversible === "string" ? { irreversible } : {}),
214
+ // r30a F1: first write → replay:"none"; duplicate only on true replay.
215
+ ...(args.requestId ? { requestId: args.requestId, idempotency: idempotencyDeclaration(tool.name, "none") } : {}),
216
+ ...(productionConfirmed ? { productionConfirmed: true } : {}),
217
+ ...(rollbackRef ? { rollback: rollbackRef } : {}),
218
+ summary: writeSummary(tool.name, args, { performed, readback }, "done"),
219
+ detailFile: artifactRef.detailFile,
220
+ detailSha256: artifactRef.detailSha256,
221
+ artifactFingerprint: artifactRef.detailSha256,
222
+ };
186
223
  });
187
224
  }
188
225
  /**
@@ -200,29 +237,105 @@ export function deliveryStateFor(readback) {
200
237
  }
201
238
  : { deliveryState: "DELIVERED" };
202
239
  }
203
- function idempotencyDeclaration(tool) {
240
+ /**
241
+ * Single source of truth for requestId replay enum strings (r30a F1).
242
+ * Help / skills must quote these constants — do not invent parallel spellings.
243
+ */
244
+ export const IDEMPOTENCY_REPLAY = Object.freeze({
245
+ none: "none",
246
+ alreadyCompleted: "already-completed",
247
+ });
248
+ export const IDEMPOTENCY_DUPLICATE = Object.freeze({
249
+ sameRequestReplayNoOp: "same-request-replay-no-op",
250
+ alreadyAbsentNoOp: "already-absent-no-op",
251
+ alreadyPublishedNoOp: "already-published-no-op",
252
+ });
253
+ function idempotencyDeclaration(tool, replay) {
254
+ if (replay === IDEMPOTENCY_REPLAY.none)
255
+ return { key: "requestId", replay: IDEMPOTENCY_REPLAY.none };
204
256
  if (tool.includes("delete"))
205
- return { key: "requestId", replay: "completed", duplicate: "already-absent-no-op" };
257
+ return { key: "requestId", replay: IDEMPOTENCY_REPLAY.alreadyCompleted, duplicate: IDEMPOTENCY_DUPLICATE.alreadyAbsentNoOp };
206
258
  if (tool.includes("publish"))
207
- return { key: "requestId", replay: "completed", duplicate: "already-published-no-op" };
208
- if (tool.includes("import"))
209
- return { key: "requestId", replay: "completed", duplicate: "same-request-replay-no-op" };
210
- return { key: "requestId", replay: "completed", duplicate: "same-request-replay-no-op" };
259
+ return { key: "requestId", replay: IDEMPOTENCY_REPLAY.alreadyCompleted, duplicate: IDEMPOTENCY_DUPLICATE.alreadyPublishedNoOp };
260
+ return { key: "requestId", replay: IDEMPOTENCY_REPLAY.alreadyCompleted, duplicate: IDEMPOTENCY_DUPLICATE.sameRequestReplayNoOp };
211
261
  }
212
- function findCompleted(root, requestId, tool, environment, requestFingerprint) {
262
+ /** Walk local write artifacts for a requestId (optionally tool/env scoped). */
263
+ export function listRequestArtifacts(root, requestId, options = {}) {
213
264
  if (!existsSync(root))
214
- return undefined;
265
+ return [];
266
+ const hits = [];
215
267
  for (const name of readdirSync(root)) {
216
268
  if (!name.endsWith(".json"))
217
269
  continue;
218
270
  try {
219
271
  const artifact = JSON.parse(readFileSync(join(root, name), "utf8"));
220
- if (artifact.deliveryState === "DELIVERED" && artifact.requestId === requestId && artifact.tool === tool && artifact.environment === environment && artifact.requestFingerprint === requestFingerprint)
221
- return { status: "ok", deliveryState: "DELIVERED", replay: "already-completed", requestId, idempotency: idempotencyDeclaration(tool), performed: artifact.performed, readback: artifact.readback };
272
+ if (artifact.requestId !== requestId)
273
+ continue;
274
+ if (options.tool !== undefined && artifact.tool !== options.tool)
275
+ continue;
276
+ if (options.environment !== undefined && artifact.environment !== options.environment)
277
+ continue;
278
+ hits.push({ file: name, artifact });
222
279
  }
223
- catch { }
280
+ catch { /* torn/corrupt artifact: skip */ }
281
+ }
282
+ return hits;
283
+ }
284
+ /**
285
+ * Idempotent replay lookup (r30a F1/F2):
286
+ * - same requestId + same fingerprint → already-completed (true no-op replay)
287
+ * - same requestId + different fingerprint → refuse request-id-args-mismatch
288
+ * - no prior hit → undefined (proceed with first write)
289
+ */
290
+ function findCompleted(root, requestId, tool, environment, requestFingerprint) {
291
+ const hits = listRequestArtifacts(root, requestId, { tool, environment });
292
+ const exact = hits.find((hit) => hit.artifact.deliveryState === "DELIVERED" && hit.artifact.requestFingerprint === requestFingerprint);
293
+ if (exact) {
294
+ const artifact = exact.artifact;
295
+ return {
296
+ status: "ok",
297
+ deliveryState: "DELIVERED",
298
+ replay: IDEMPOTENCY_REPLAY.alreadyCompleted,
299
+ requestId,
300
+ idempotency: idempotencyDeclaration(tool, IDEMPOTENCY_REPLAY.alreadyCompleted),
301
+ performed: artifact.performed,
302
+ readback: artifact.readback,
303
+ };
224
304
  }
225
- return undefined;
305
+ const prior = hits.find((hit) => hit.artifact.deliveryState === "DELIVERED" || hit.artifact.deliveryState === "WRITE_UNVERIFIED" || hit.artifact.deliveryState === "STOPPED");
306
+ if (!prior)
307
+ return undefined;
308
+ const priorArtifact = prior.artifact;
309
+ const performed = (priorArtifact.performed ?? {});
310
+ const existingBizObjectId = typeof performed.bizObjectId === "string"
311
+ ? performed.bizObjectId
312
+ : typeof priorArtifact.readback?.bizObjectId === "string"
313
+ ? String(priorArtifact.readback.bizObjectId)
314
+ : undefined;
315
+ const envFlag = `--env ${environment}`;
316
+ return {
317
+ status: "refused",
318
+ blocker: "request-id-args-mismatch",
319
+ requestId,
320
+ argsDigestChanged: true,
321
+ existingRequestFingerprint: priorArtifact.requestFingerprint ?? null,
322
+ ...(existingBizObjectId !== undefined ? { existingBizObjectId } : {}),
323
+ prior: {
324
+ tool: priorArtifact.tool,
325
+ environment: priorArtifact.environment,
326
+ deliveryState: priorArtifact.deliveryState,
327
+ recordedAt: priorArtifact.recordedAt,
328
+ artifactFile: prior.file,
329
+ },
330
+ message: `同一个 requestId「${requestId}」已经用过不同参数写过(argsDigestChanged:true)。` +
331
+ `requestId 是 (id, 参数指纹) 组合键——换参数必须换 requestId,否则拒绝,避免悄悄写出第二条。`,
332
+ nextCommand: `omc call request.status ${envFlag} --args '{"requestId":"${requestId}"}'`,
333
+ recovery: {
334
+ whatWentWrong: "request-id-args-mismatch",
335
+ fixFirst: `换一个未用过的 --request-id 再发;或先 omc call request.status ${envFlag} --args '{"requestId":"${requestId}"}' 查已有结果`,
336
+ doNot: "不要用同一 requestId 换参数期望覆盖/重放——那会拒绝,不会更新原写",
337
+ },
338
+ };
226
339
  }
227
340
  /** Low-level write registration factory: shared by every lane adapter. */
228
341
  export function writeTool(definition) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,9 +54,8 @@
54
54
  "access": "public"
55
55
  },
56
56
  "dependencies": {
57
- "@stalfh233/omc-fake-platform": "0.6.11",
58
57
  "commander": "14.0.3",
59
- "omc-core": "0.6.11",
58
+ "omc-core": "0.6.13",
60
59
  "proper-lockfile": "4.1.2",
61
60
  "write-file-atomic": "6.0.0",
62
61
  "zod": "^3.23.8",
@@ -72,5 +71,8 @@
72
71
  "type": "git",
73
72
  "url": "https://gitlab.h3yun.com/fuwenbin/oh-my-cpagents.git",
74
73
  "directory": "packages/omc-cli"
74
+ },
75
+ "optionalDependencies": {
76
+ "@stalfh233/omc-fake-platform": "0.6.13"
75
77
  }
76
78
  }
@@ -152,7 +152,7 @@ rules:
152
152
 
153
153
  - **两条路**:模型+表单+流程一揽子交付走**意图**(`intent` 的 `workflow` 能力,声明式一次 apply);只动流程或想逐步控制走**低层链** `workflow.create → workflow.update(权限/节点)→ workflow.publish`,标准链配方见 `omc describe workflow.update`(含多级审批/participant 三形态/自动回填说明)。流程权限细节见下节。
154
154
  - 一个模型只能有一个流程;已有流程的模型直接 `workflow.update`,不要另建。
155
- - **清理测试数据用批量删除**:`data.delete --args '{"schemaCode":"<code>","bizObjectIds":["id1","id2"]}'` 一次往返删多条(真机 r6:207 次逐条 `data.delete` 占 13.5% 调用)。
155
+ - **清理测试数据用逐条删除**:1.1.x 上 `bizObjectIds:[多个]` 是假成功(`data-delete-batch-fake-success`)。用 `--args-list` 一次跑多条单删:`omc call data.delete --args-list '[{"schemaCode":"<code>","bizObjectId":"id1"},{"schemaCode":"<code>","bizObjectId":"id2"}]'`,再逐条 `data.load` 确认 `exists:false`(见 `describe data.delete --example sweep-all-readback`)。
156
156
  - **「聚合计算」是字段能力,不是规则节点**:FORMULA(计算公式)字段的 `options.formulaConfig.formulaType="AGGREGATION"` 就是 UI 里的「配置方式 → 聚合计算」。**只能汇总另一个模型**(跨模型),**同模型/自己的子表会 305003**(`field.publish` 时 `publishFormula` 把相对码改写成子表父模型 = 本模型,`checkCycleOfAggregation:1032` 判成环;工具已写前拒绝 `aggregation-self-schema-cycle`)。可直接抄 `omc describe field.create --example formula-aggregation-cross-model`。形状:
157
157
  `options = {persistent:true, formulaConfig: JSON字符串({resultType:2, formulaType:"AGGREGATION", aggregation: JSON字符串({schemaCode:"<来源模型>", dataItemCode:"<来源列>", calculateType:"SUM", formula:"SUM({<来源模型>.<列>})", queryConditions:[[{propertyCode:"<来源模型字段>", currentPropertyCode:"<目标模型字段>", operatorType:1, valueType:"DYNAMIC"}]]})})}`。
158
158
  - **`queryConditions` 不能为空**:它是反向过滤器的唯一输入,空数组时回算日志照打但目标字段恒 0(r17 实测)。
@@ -67,9 +67,9 @@ only the section for the capability you need —
67
67
  ## 数据字典 `dictionary`
68
68
 
69
69
  - **能做哪些事**:字典与字典记录全生命周期(键值型/树型、启停);作为选择字段的选项来源;字典分类。
70
- - **工具**:`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
+ - **工具**:`dictionary.list`、`dictionary.type`、`dictionary.names`、`dictionary.get`、`dictionary.records`、`dictionary.save`、`dictionary.status`、`dictionary.usage`、`dictionary.references`、`dictionary.parent`、`dictionary.classes`、`dictionary.class.save`、`dictionary.class.delete`
71
71
  - **recipe**:`omc describe dictionary.save --example dictionary-key-value`
72
- - **边界 / 陷阱**:选择字段有三种选项来源——`custom` / `dictionary` / `businessModel`;关联单选/多选支持 `searchFormula` 与 `conditions` JSON 筛选。**字典没有删除**——平台只有停用/启用(`dictionary.status`,target= dictionary|record);不要找 dictionary.delete(分类有 `class.delete`,字典本体没有),清理测试残留用停用即可。
72
+ - **边界 / 陷阱**:选项来源 `custom`/`dictionary`/`businessModel`;关联可用 `searchFormula`/`conditions`。**无字典删除**——只能 `dictionary.status` 停用。`dictionary.usage`=写 initialUsed(非引用查询);引用扫描用 `dictionary.references`。
73
73
 
74
74
  ## 业务模型 `model`
75
75
 
@@ -152,8 +152,8 @@ only the section for the capability you need —
152
152
 
153
153
  - **能做哪些事**:流程模板 get_draft/get_published、create、update、publish、bindRule(事件处理器规则绑定)、delete、workitem.list、workflow.instanceActivity(实例节点状态诊断);参与人用组织对象。
154
154
  - **工具**:`workflow.get_draft`、`workflow.setButtons`(节点「表单操作」按钮勾选)、`workflow.get_published`、`workflow.validate`、`workflow.create`、`workflow.update`、`workflow.publish`、`workflow.bindRule`、`workflow.delete`、`workflow.permissions`、`workflow.addPermission`、`workflow.removePermission`(发起权限:发布流程不会自动建这一行,缺它一律 40009)、`configure_workflow`、`workitem.list`、`workflow.instanceActivity`、`org.role.list`、`org.department.list`、`org.user.search`、`org.role.users`、`org.role.userScope`、`org.roleGroup.create`、`org.role.create`、`org.role.update`、`org.role.addUsers`、`org.role.removeUsers`、`org.role.setUserScope`、`org.department.create`、`org.department.update`、`org.department.delete`、`org.department.users`、`org.user.create`、`org.user.update`、`org.user.delete`、`org.user.info`、`org.user.departments`、`org.role.delete`、`org.roleGroup.delete`、`org.user.transferDepartment`
155
- - **recipe**:`omc describe workflow.bindRule --example workflow-handler-binding`
156
- - **边界 / 陷阱**:greenfield 首版;`publish`/`bindRule` 不得在 `ValidateMessage[]` 拒绝时报告成功。运行时 workitem(提交/审批)走 `data.submit`;想知道实例停在哪/是否错派,用 `workflow.instanceActivity`(无 status=1=真停,参与人全是发起人=解析为空退回)。
155
+ - **recipe**:`omc describe workflow.bindRule --example workflow-handler-binding`;条件两级/一级:`omc describe workflow.update --example workflow-conditional-two-level-approval`
156
+ - **边界 / 陷阱**:greenfield 首版;`publish`/`bindRule` 不得在 `ValidateMessage[]` 拒绝时报告成功。运行时 workitem(提交/审批)走 `data.submit`;想知道实例停在哪/是否错派,用 `workflow.instanceActivity`(无 status=1=真停,参与人全是发起人=解析为空退回)。**发起权限**(`workflow.permissions` / `addPermission`)只有部门/角色/用户三种白名单,**没有「全员」选项**——缺权限行时非管理员通常不能发起(40009),想放开只能授一个够宽的部门/角色,不是「不建行=全员」。**行级数据权限 / 可见范围 / 申请人只看自己的单据不在工具覆盖内**(没有按登录人动态过滤的读面/写面);`org.role.setUserScope` 是角色成员的部门管理范围,不是单据行级可见范围。
157
157
 
158
158
  ## 表单在线 JS `onlinejs`
159
159
 
@@ -207,6 +207,7 @@ only the section for the capability you need —
207
207
  | 面 | 状态 | 说明 |
208
208
  | --- | --- | --- |
209
209
  | 权限 / 授权矩阵 | blocked | 授权矩阵不在配置面 |
210
+ | **行级数据权限 / 可见范围 / 本人单据** | unsupported | 没有按登录人动态过滤的读面/写面;`workflow.permissions` 只管谁能**发起**;`org.role.setUserScope` 是角色成员部门管理范围。搜「数据权限」「可见范围」「行级权限」「本人」应命中本行——不是词写错,是能力缺口 |
210
211
  | 嵌套子表(子表套子表) | 平台不存在 | 平台本身就是一级子表:主表可多子表,子表不能再套子表;不是 CLI 缺口,不要按此设计 |
211
212
  | OCR / 电子签章 / 发票识别 | unsupported | 不在平台配置覆盖内 |
212
213
  | 应用发布 / 环境上线 / 生产发布 | unsupported | CLI 只做配置,不做环境发布 |
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: omc-playbook
3
+ description: 一页纸业务场景步骤清单(采购申请 / 请假审批 / 基础档案)。多面需求先看这里,再按步执行。
4
+ ---
5
+
6
+ # Playbook(业务场景一页纸)
7
+
8
+ 多面需求不要靠自己拼 3–4 个 1KB+ 方法论节。这里每步一条命令。
9
+
10
+ 入口:`omc next "<目标>"`(多面 → `mode:whole-package`)→ 本页 → 逐步执行。
11
+
12
+ ## 采购申请(主子表 + 合计 + 条件审批 + 列表 + 按钮)
13
+
14
+ 1. `omc next "从零做采购申请:主子表、合计、条件审批、列表、按钮"`
15
+ 2. `omc intent schema`(或直接按下方拼装器链)
16
+ 3. `omc model-scaffold --app <app> --code <code> --name 采购申请 --fields '[{"code":"title","name":"标题","propertyType":"SHORT_TEXT"},{"code":"amount","name":"合计","propertyType":"NUMERICAL","options":{"scale":2}},{"code":"details","name":"明细","propertyType":"CHILD_TABLE","subFields":[{"code":"item","name":"品名","propertyType":"SHORT_TEXT"},{"code":"qty","name":"数量","propertyType":"NUMERICAL","options":{"scale":0}},{"code":"price","name":"单价","propertyType":"NUMERICAL","options":{"scale":2}},{"code":"lineAmount","name":"金额","propertyType":"NUMERICAL","options":{"scale":2}}]}]' --apply`
17
+ 4. `omc compute --schema <code> --target amount --formula 'SUM({details.lineAmount})' --apply`(⚠️ 只在门户算,REST 不回填)
18
+ 5. 条件审批(金额>50000 两级):**不要** `approval-flow --condition`(无此参数)。线性先:`omc approval-flow --schema <code> --steps '经理@role:<mgr>' --steps '总监@role:<dir>' --apply`;条件分流改 `omc describe workflow.update --example workflow-conditional-branch` 后 `workflow.update`。
19
+ 6. 发起权限:`omc call workflow.addPermission --args '{"workflowCode":"<code>_wf","unitId":"<deptOrRoleId>","unitType":1}'`(这是**谁能发起**,不是行级可见范围)
20
+ 7. 列表:`omc call listview.configure --args '{"schemaCode":"<code>","fieldCodes":["title","amount"]}'`
21
+ 8. 表单按钮:`omc call listview.configure --args '{"schemaCode":"<code>","buttons":[{"name":"提交审批","useLocation":"FORM","bindAction":"FLOW"}]}'`(形状以 describe 为准)
22
+ 9. ⚠️ **行级「申请人只看自己」当前工具面不支持**(`workflow.permissions` / `org.role.setUserScope` 都不是行级数据权限)
23
+
24
+ ## 请假审批(线性流)
25
+
26
+ 1. `omc model-scaffold --code leave --name 请假 --fields '[{"code":"days","name":"天数","propertyType":"NUMERICAL","options":{"scale":1}},{"code":"reason","name":"事由","propertyType":"LONG_TEXT"}]' --apply`
27
+ 2. `omc approval-flow --schema leave --steps '主管@role:<code>' --apply`
28
+ 3. `omc call workflow.addPermission --args '{"workflowCode":"leave_wf","unitId":"<id>","unitType":2}'`
29
+ 4. 造单验:`omc describe data.submit --example workflow-backed-data-submit`
30
+
31
+ ## 基础档案(无流程)
32
+
33
+ 1. `omc model-scaffold --code vendor --name 供应商 --fields '[{"code":"name","name":"名称","propertyType":"SHORT_TEXT"},{"code":"code","name":"编码","propertyType":"SHORT_TEXT"}]' --apply`
34
+ 2. `omc call listview.configure --args '{"schemaCode":"vendor","fieldCodes":["name","code"]}'`
35
+ 3. 造数:`omc describe data.submit --example plain-data-submit`
36
+
37
+ ## 读法
38
+
39
+ - 默认索引:`omc help omc-playbook`(看节摘要)
40
+ - 按节:`omc help omc-playbook --section "采购申请"`
41
+ - 下次可按节读:`omc help <topic> --section <标题>`