@stalfh233/omc-cli 0.4.35 → 0.4.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -246,12 +246,12 @@ src/ args/support/plan/read/write/package/context(治理管道)
246
246
 
247
247
  ## npm 分发
248
248
 
249
- 本包(`@stalfh233/omc-cli`,可执行命令仍是 `omc`)通过公共 npm registry 分发。`omc-core` 是 `omc-cli` 的内部依赖,发布时先发 `omc-core`,再发 `omc-cli`;两者版本保持一致。当前版本为 `0.4.15`,尚未宣称 `1.0.0` 稳定契约。
249
+ 本包(`@stalfh233/omc-cli`,可执行命令仍是 `omc`)通过公共 npm registry 分发。`omc-core` 是 `omc-cli` 的内部依赖,发布时先发 `omc-core`,再发 `omc-cli`;两者版本保持一致。当前版本为 `0.4.36`,尚未宣称 `1.0.0` 稳定契约。
250
250
 
251
251
  ### 使用已发布包
252
252
 
253
253
  ```bash
254
- npm install -g @stalfh233/omc-cli@0.4.15
254
+ npm install -g @stalfh233/omc-cli@latest
255
255
  omc setup
256
256
  omc doctor
257
257
  omc help omc-write-hazards
package/dist/cli.js CHANGED
@@ -14,30 +14,21 @@ import { CLI_EXIT_CODES, classifyResult, envelope, errorEnvelope, requestId } fr
14
14
  import { configureOutput, humanSummary, stripOutputFlags, writeEnvelope } from "./cli-output.js";
15
15
  import { beginUsage, completeUsage, usageReport } from "./usage-log.js";
16
16
  import { artifactFetchHint, artifactRoot, boundResult, emitInvalid, emitResult, emitTransportResult, outputBudget, readArtifact, withTransport, } from "./command-transport.js";
17
- import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath, createArtifact } from "./artifacts.js";
18
- import { jsonBytes, sha256, writeArtifact } from "./support.js";
19
- import { join, resolve } from "node:path";
20
- import { writeFileSync } from "node:fs";
17
+ import { CLI_DEFAULT_OUTPUT_BYTES, assertSafeOutputPath } from "./artifacts.js";
18
+ import { resolve } from "node:path";
21
19
  import { buildToolRegistry } from "./tools.js";
22
20
  import { acquireWriteLock } from "./write-lock.js";
23
21
  import { commandIndex, commandInventory, describeContract, describeContractFull, describeContractTool, describeExample, describeParam, describeSchemaOnly, describeTool, describeToolFull } from "./discovery.js";
24
22
  import { buildCapabilityOverview, capabilityDetail, compactCapabilities, executableCapabilityBundle, resolveCapabilityId, DISCOVERY_AUTHORITY } from "./capability-overview.js";
25
23
  import { goalRoute } from "./goal-routes.js";
26
24
  import { absencePointer } from "./capability-absences.js";
27
- import { intentConfigRoot, readIntentFile, validateIntentText, INTENT_COMPILER_VERSION } from "./intent/cli.js";
25
+ import { intentConfigRoot, readIntentFile, validateIntentText } from "./intent/cli.js";
28
26
  import { intentSchemaBlock, intentExample, intentExampleIds } from "./intent/schema.js";
29
- import { baselineFingerprintOf, readBaseline } from "./intent/baseline.js";
30
- import { compileIntent } from "./intent/compiler.js";
31
- import { expectedRuleGraph } from "./intent/capabilities/businessrule.js";
32
- import { validateRuleNodeGraph, PLATFORM_TRIGGER_RULE_CODES, walkConditionLeaves } from "./lanes/rule-graph.js";
33
- import { buildPlan, verifyPlan } from "./intent/plan-file.js";
34
- import { compareWithLive, decodeBaseline } from "./intent/readback.js";
27
+ import { planIntent, readbackIntent, applyIntent, intentWarnings } from "./intent/plan-apply.js";
35
28
  import { listSkills, readSkill, readSkillSection } from "./skills.js";
36
- import { parseToolInput, ToolInputError, ToolRefusalError } from "./tool-types.js";
29
+ import { parseToolInput, ToolInputError } from "./tool-types.js";
37
30
  import { WireError, wireCall } from "./wire.js";
38
31
  import { ENDPOINTS } from "./endpoints.js";
39
- import { makeToolContext } from "./context.js";
40
- import { SessionManager } from "./session-manager.js";
41
32
  async function ask(question) {
42
33
  const rl = createInterface({ input: stdin, output: process.stderr });
43
34
  try {
@@ -692,95 +683,49 @@ function intentDigest(intent) {
692
683
  ...(intent.reports !== undefined ? { reportCodes: intent.reports.map((report) => report.code) } : {}),
693
684
  };
694
685
  }
695
- /**
696
- * Warning-severity findings the declarative face must not swallow. The rule
697
- * dry-lint computes warnings (e.g. `rule-graph-cross-model-unverified`: a
698
- * cross-model UPDATE saves+publishes but was observed to write nothing at
699
- * runtime — bench50/52/53/55); `validateRuleNodeGraph` is the single source, so
700
- * this reuses it rather than re-deriving semantics.
701
- */
702
- function intentWarnings(intent) {
703
- const warnings = [];
704
- for (const model of intent.models) {
705
- // Field-level data rules of type 3/4/5 (拼接/必填/只读) are PORTAL-FACING, but
706
- // the platform stores them as bizdatarule rows and mirrors them only into the
707
- // form control's `historyOpt` — while the runtime portal reads the control's
708
- // `options`. So a datarule-only required/readonly renders/stays inert in the
709
- // portal (live 2026-09-28: bench62 datarule type4 on order_note, published
710
- // control had no requiredFormula; the live portal did not require it). The
711
- // portal-facing condition must be declared in `fields[].options`.
712
- for (const datarule of model.datarules ?? []) {
713
- if ([3, 4, 5].includes(datarule.dataRuleType))
714
- warnings.push({
715
- resource: `datarule:${model.code}.${datarule.propertyCode}`,
716
- code: "datarule-not-portal-facing",
717
- message: `字段级数据规则 type ${datarule.dataRuleType}(${datarule.dataRuleType === 4 ? "条件必填" : datarule.dataRuleType === 5 ? "条件只读" : "文本拼接"})不驱动门户表单控件:平台把它存成 bizdatarule、只镜像进控件 historyOpt,而运行时门户读控件 options(真机复核:规则已存但发布控件无该字段、门户不拦)。要让门户生效,把条件写到字段的 options 里(fields[].options.requiredFormula / readonlyCondition / shortTextStitch)——这条路径已真机验证可穿透到发布控件;datarule 只留给服务端字段级引擎(校验/计算)。`,
718
- });
719
- // Dual-write advisory (dogfood R1): when the SAME field already declares
720
- // the portal-facing condition in `fields[].options`, the datarule row is
721
- // redundant. Surface it rather than silently writing both.
722
- const field = model.fields.find((candidate) => candidate.code === datarule.propertyCode);
723
- const fieldOptions = field?.options ?? {};
724
- const duplicateKey = datarule.dataRuleType === 4 ? "requiredFormula" : datarule.dataRuleType === 5 ? "readonlyCondition" : undefined;
725
- if (duplicateKey !== undefined && fieldOptions[duplicateKey] !== undefined)
726
- warnings.push({
727
- resource: `datarule:${model.code}.${datarule.propertyCode}`,
728
- code: "datarule-duplicates-field-options",
729
- message: `字段 ${model.code}.${datarule.propertyCode} 同时在 fields[].options.${duplicateKey} 和 datarules[](type ${datarule.dataRuleType})声明了同一条件——门户只读 options,datarule 那条是冗余(服务端字段级引擎用)。若要门户生效保留 options 即可;datarule 仅在需要服务端强制时才必须保留。`,
730
- });
686
+ /** Ticket 118 — `omc intent plan <file> --env <env>`: read-only baseline -> compile -> immutable plan. */
687
+ async function cmdIntentPlan(file, environment, planOut, transport) {
688
+ const read = readIntentOrInvalid(file, transport);
689
+ if (read.kind === "exit")
690
+ return read.code;
691
+ if (planOut !== undefined) {
692
+ try {
693
+ assertSafeOutputPath(intentConfigRoot(), planOut, "plan output file");
694
+ }
695
+ catch (error) {
696
+ return emitInvalid(transport, { message: error instanceof Error ? error.message : String(error) });
731
697
  }
732
- for (const rule of model.rules ?? []) {
733
- // Preset trigger rules are platform-managed: their `name` is a platform
734
- // default (数据新增/数据更新/…). `rule.save` keeps the stored name for these
735
- // codes, so a declared `name` is ignored — say so rather than silently
736
- // dropping it (a caller who meant to rename would otherwise be confused).
737
- if (PLATFORM_TRIGGER_RULE_CODES.has(rule.code) && rule.nameImplicit !== true)
738
- warnings.push({
739
- resource: `rule:${model.code}.${rule.code}`,
740
- code: "rule-preset-name-ignored",
741
- message: `规则 ${rule.code} 是平台预置触发规则,其名称由平台维护(如 Create=数据新增);声明的 name "${rule.name}" 不会生效(只有自定义规则的名称与节点名可改)——预置行可直接省略 name 以消除本警告;要改显示名请改 nodeGraph/actions 中节点的 nodeName。`,
742
- });
743
- let graph;
698
+ }
699
+ return emitResult(await planIntent(read.intent, environment, planOut), transport);
700
+ }
701
+ /** Ticket 120 — `omc intent readback <file> --env <env>`: decode the live baseline back into intent shape and verify equivalence. */
702
+ async function cmdIntentReadback(file, environment, transport) {
703
+ const read = readIntentOrInvalid(file, transport);
704
+ if (read.kind === "exit")
705
+ return read.code;
706
+ return emitResult(await readbackIntent(read.intent, environment), transport);
707
+ }
708
+ /** Ticket 119 — `omc intent apply <plan-file>`: plan integrity + baseline drift gates, then serial execution through the EXISTING apply_changes machinery. */
709
+ async function cmdIntentApply(planPath, options, transport) {
710
+ return applyIntent(planPath, options, {
711
+ emit: (result) => emitResult(result, transport),
712
+ invalid: (blocker, message, code = "invalid-input") => emitInvalid(transport, { message, blocker, code }),
713
+ readIntent: (file) => {
714
+ let outcome;
744
715
  try {
745
- graph = expectedRuleGraph(rule, model.code);
716
+ outcome = validateIntentText(readIntentFile(intentConfigRoot(), file));
746
717
  }
747
718
  catch {
748
- continue; // shape errors are surfaced by validate/plan already
749
- }
750
- // Round 16 (bench66): `filter` LOCATES the rows to update, so a same-model
751
- // action whose filter uses only non-unique fields updates EVERY matching
752
- // row, not just the submitted one. bench66 produced status=中额 for a
753
- // 120000 order because the ladder's overlapping amount-filters each matched
754
- // the one row and the last one won — and a real table would have had its
755
- // other rows clobbered. Keep bulk expressible (warn, don't gate) but name
756
- // both fixes: locate self with `filter: [{field:id, from:id}]`, or gate the
757
- // action with `when:` (an execution-gate `dataCondition`).
758
- for (const node of graph.nodes ?? []) {
759
- if (String(node.nodeType ?? "") !== "UPDATE")
760
- continue;
761
- const target = String(node.targetObjectCode ?? "");
762
- const sameModel = target === model.code || target.startsWith(`${model.code}.`);
763
- if (!sameModel)
764
- continue;
765
- const leaves = [];
766
- walkConditionLeaves(node.filterCondition, (leaf) => leaves.push(leaf));
767
- if (leaves.length === 0)
768
- continue; // no filter = default self-identity, safe
769
- if (leaves.some((leaf) => String(leaf.targetSchemaDataItem) === "id"))
770
- continue;
771
- warnings.push({
772
- resource: `rule:${model.code}.${rule.code}`,
773
- code: "rule-locator-not-unique",
774
- message: `规则 ${rule.code} 的 UPDATE 节点定位条件(filterCondition)未用唯一键 id:filter 是「定位要更新哪些行」,用非唯一字段(如金额/状态)会命中并更新**多条**记录(真机 bench66:金额分级误配导致多行互相覆盖)。只更新当前这条用 filter: [{field: id, from: id}](省略 filter 时同模型动作已默认按自身 id 定位);「输入数据满足条件才执行该节点」是执行门,用 when: [{field: <字段>, gt: <值>}](编译到 dataCondition),与 filter 是两码事。确属批量更新可忽略本告警。`,
775
- });
719
+ return { kind: "exit", code: CLI_EXIT_CODES.invalidInput };
776
720
  }
777
- for (const finding of validateRuleNodeGraph(graph, rule.code, model.code)) {
778
- if (finding.severity === "warning")
779
- warnings.push({ resource: `rule:${model.code}.${rule.code}`, code: finding.code, message: finding.message });
721
+ if (!outcome.valid || outcome.intent === undefined) {
722
+ for (const error of outcome.errors)
723
+ console.error(`intent ${file}:${error.line}:${error.column} [${error.code}] ${error.message}`);
724
+ return { kind: "exit", code: CLI_EXIT_CODES.invalidInput };
780
725
  }
781
- }
782
- }
783
- return warnings;
726
+ return { kind: "ok", intent: outcome.intent };
727
+ },
728
+ });
784
729
  }
785
730
  /** Ticket 117 — `omc intent validate <file>`: offline, zero environment contact. */
786
731
  function cmdIntentValidate(file, transport, showIntent = false) {
@@ -807,451 +752,6 @@ function cmdIntentSchema(transport, example, exampleRequested) {
807
752
  }
808
753
  return emitResult(intentSchemaBlock(), transport);
809
754
  }
810
- /** Context-cost guard: an inline plan whose JSON exceeds this (or whose decoded baseline does) is externalized to a file (see cmdIntentPlan). A populated environment's baseline is ~53 KB; a rich greenfield intent's plan is ~20 KB — both charged on every later turn. */
811
- const INTENT_PLAN_INLINE_CEILING_BYTES = 16384;
812
- /** Ticket 118 — `omc intent plan <file> --env <env>`: read-only baseline -> compile -> immutable plan. */
813
- async function cmdIntentPlan(file, environment, planOut, transport) {
814
- const read = readIntentOrInvalid(file, transport);
815
- if (read.kind === "exit")
816
- return read.code;
817
- const intent = read.intent;
818
- // Validate the optional plan output path BEFORE any environment contact, so a
819
- // bad path is a clean invalid-input (exit 10), never a wire attempt.
820
- let outPath;
821
- if (planOut !== undefined) {
822
- try {
823
- outPath = assertSafeOutputPath(intentConfigRoot(), planOut, "plan output file");
824
- }
825
- catch (error) {
826
- return emitInvalid(transport, { message: error instanceof Error ? error.message : String(error) });
827
- }
828
- }
829
- let result;
830
- try {
831
- const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
832
- const session = await context.sessions.session(environment);
833
- const baseline = await readBaseline(session, intent);
834
- const compiled = compileIntent(intent, baseline);
835
- const plan = buildPlan({
836
- environment: session.environmentName,
837
- environmentFingerprint: session.environmentFingerprint(),
838
- compiler: INTENT_COMPILER_VERSION,
839
- baseline,
840
- intent,
841
- compiled,
842
- });
843
- const planJson = JSON.stringify(plan, null, 2) + "\n";
844
- const artifact = createArtifact(artifactRoot(), planJson);
845
- const warnings = intentWarnings(intent);
846
- const toolCounts = new Map();
847
- for (const change of compiled.changes)
848
- toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
849
- const changeSummary = [...toolCounts.entries()].map(([tool, count]) => ({ tool, count }));
850
- const changePhrase = changeSummary.map(({ tool, count }) => (count > 1 ? `${tool}×${count}` : tool)).join("、") || "无";
851
- console.error(`intent plan ${plan.planHash.slice(0, 12)}:${compiled.changes.length} 项变更(${changePhrase}),${compiled.skipped.length} 项已达标跳过,风险=${compiled.riskClass};环境=${plan.environment}`);
852
- if (planOut !== undefined) {
853
- // Lean route (2026-09-27): the plan is the immutable approval object and
854
- // its full JSON is ~11 KB for a real requirement — inlining it charges the
855
- // agent's window on every later turn. Write the byte-identical plan to a
856
- // file inside the config root (so `intent apply` can read it) and return
857
- // only the decision fields + the path.
858
- writeFileSync(outPath, planJson, { encoding: "utf8", mode: 0o600 });
859
- result = {
860
- status: "ok",
861
- planId: plan.planHash.slice(0, 12),
862
- planHash: plan.planHash,
863
- // Echo the caller's own path string: an absolute host path would be
864
- // redacted by the envelope, and the caller already knows the name.
865
- planFile: planOut,
866
- changesCount: compiled.changes.length,
867
- skippedCount: compiled.skipped.length,
868
- riskClass: compiled.riskClass,
869
- changeSummary,
870
- planArtifactId: artifact.artifactId,
871
- planArtifactSha256: artifact.sha256,
872
- nextAction: `omc intent apply ${planOut}`,
873
- };
874
- }
875
- else {
876
- // Context-cost guard (round 15): a populated environment's decoded
877
- // baseline makes the inline plan 50–60 KB, and a rich greenfield intent's
878
- // plan is ~20 KB — either is the single largest payload any read face
879
- // emits, charged on every later turn. Above the ceiling, externalize the
880
- // (byte-identical) immutable plan to the config root, which `intent apply`
881
- // can read, and return only decision fields + the path. Small plans stay
882
- // inline (deterministic on size).
883
- const baselineBytes = jsonBytes(baseline);
884
- const overCeiling = planJson.length > INTENT_PLAN_INLINE_CEILING_BYTES || baselineBytes > INTENT_PLAN_INLINE_CEILING_BYTES;
885
- // If the config root is not writable, fall back to the inline plan (the
886
- // pre-existing behaviour) rather than turning a read-only cwd into a
887
- // failure.
888
- let externalized = false;
889
- let planFileName = "";
890
- if (overCeiling) {
891
- planFileName = `intent-plan-${plan.planHash.slice(0, 12)}.json`;
892
- try {
893
- writeFileSync(join(intentConfigRoot(), planFileName), planJson, { encoding: "utf8", mode: 0o600 });
894
- externalized = true;
895
- }
896
- catch {
897
- externalized = false;
898
- }
899
- }
900
- if (externalized) {
901
- result = {
902
- status: "ok",
903
- planId: plan.planHash.slice(0, 12),
904
- planHash: plan.planHash,
905
- planFile: planFileName,
906
- changesCount: compiled.changes.length,
907
- skippedCount: compiled.skipped.length,
908
- riskClass: compiled.riskClass,
909
- changeSummary,
910
- planArtifactId: artifact.artifactId,
911
- planArtifactSha256: artifact.sha256,
912
- planExternalized: true,
913
- ...(warnings.length > 0 ? { warnings } : {}),
914
- note: `plan ${planJson.length}B(基线 ${baselineBytes}B)超过内联上限 ${INTENT_PLAN_INLINE_CEILING_BYTES}B,未内联,已写入 ${planFileName};直接 ${"`omc intent apply " + planFileName + "`"}`,
915
- nextAction: `omc intent apply ${planFileName}`,
916
- };
917
- }
918
- else {
919
- result = {
920
- status: "ok",
921
- planId: plan.planHash.slice(0, 12),
922
- planHash: plan.planHash,
923
- changesCount: compiled.changes.length,
924
- skippedCount: compiled.skipped.length,
925
- riskClass: compiled.riskClass,
926
- plan,
927
- planArtifactId: artifact.artifactId,
928
- planArtifactSha256: artifact.sha256,
929
- ...(warnings.length > 0 ? { warnings } : {}),
930
- };
931
- }
932
- }
933
- }
934
- catch (error) {
935
- if (error instanceof ToolRefusalError)
936
- result = error.payload;
937
- else if (error instanceof WireError)
938
- result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
939
- else
940
- result = { status: "error", message: error instanceof Error ? error.message : String(error) };
941
- }
942
- return emitResult(result, transport);
943
- }
944
- /**
945
- * Spec 107 readback core: decode the authoritative live baseline back into
946
- * intent shape and compare it to the source intent. Shared by `intent readback`
947
- * (equivalent:false is a legitimate not-yet-applied state, exit 0) and, since
948
- * ticket 123, by `intent apply` as the final confirmation step, where a
949
- * mismatch must not be reported as plain success.
950
- */
951
- async function verifyIntentLive(session, intent) {
952
- const baseline = await readBaseline(session, intent);
953
- const live = decodeBaseline(baseline, intent);
954
- const report = compareWithLive(intent, live);
955
- return {
956
- environment: session.environmentName,
957
- environmentFingerprint: session.environmentFingerprint(),
958
- baselineFingerprint: baselineFingerprintOf(baseline),
959
- equivalent: report.equivalent,
960
- differences: report.differences,
961
- ignoredOutsideIntent: report.ignoredOutsideIntent,
962
- live,
963
- };
964
- }
965
- /**
966
- * Route the decoded `live` blob to an artifact once it exceeds the inline
967
- * ceiling, keeping only the decision fields (equivalent / differences /
968
- * counts). Real requirements decode to tens of KB: round 9 (sandbox 43) an
969
- * `intent readback` was 62 KB × ~10 calls because it inlined the full live
970
- * state, and the agent redirected each to a file to avoid the context cost.
971
- * Same diet as governedRead's full ceiling (src/read.ts) and the same
972
- * reachability guarantee: the blob stays fully available via the artifact.
973
- */
974
- const INTENT_LIVE_INLINE_CEILING_BYTES = 8192;
975
- /**
976
- * Context-cost (round 15): `ignoredOutsideIntent` lists every resource outside
977
- * the intent (ownership-ignored, never a difference). A real model carries ~40
978
- * system fields, so a 3-model intent reported 212 entries = ~5.7 KB of pure
979
- * noise on every readback — twice per task. Inline a bounded sample plus the
980
- * true count; the list is informational, so nothing decision-making is lost.
981
- */
982
- const INTENT_IGNORED_INLINE_LIMIT = 20;
983
- function capIgnoredOutsideIntent(verification) {
984
- const ignored = verification.ignoredOutsideIntent;
985
- if (!Array.isArray(ignored) || ignored.length <= INTENT_IGNORED_INLINE_LIMIT)
986
- return {};
987
- return {
988
- ignoredOutsideIntent: ignored.slice(0, INTENT_IGNORED_INLINE_LIMIT),
989
- ignoredOutsideIntentCount: ignored.length,
990
- ignoredOutsideIntentHint: `意图外资源共 ${ignored.length} 项(按所有权忽略,不是差异);此处只内联前 ${INTENT_IGNORED_INLINE_LIMIT} 项`,
991
- };
992
- }
993
- function dietIntentLive(verification, context) {
994
- const capped = capIgnoredOutsideIntent(verification);
995
- const live = verification.live;
996
- if (live === undefined)
997
- return { ...verification, ...capped };
998
- const bytes = jsonBytes(live);
999
- if (bytes <= INTENT_LIVE_INLINE_CEILING_BYTES)
1000
- return { ...verification, ...capped };
1001
- const { live: _omitted, ...rest } = verification;
1002
- const ref = writeArtifact(context, `intent-live-${sha256(JSON.stringify(live)).slice(0, 16)}.json`, live);
1003
- return {
1004
- ...rest,
1005
- ...capped,
1006
- liveOmitted: true,
1007
- liveBytes: bytes,
1008
- liveArtifactHint: `权威 live 状态 ${bytes}B 超过内联上限 ${INTENT_LIVE_INLINE_CEILING_BYTES}B,已外置;上面的 equivalent/differences 已是决策所需,需要原生状态用 \`omc artifact get <detailArtifactId>\`(可 --jq <path>)定向取回`,
1009
- detailFile: ref.detailFile,
1010
- detailSha256: ref.detailSha256,
1011
- };
1012
- }
1013
- /**
1014
- * Ticket 120 — `omc intent readback <file> --env <env>`: decode the live
1015
- * baseline back into intent shape and verify equivalence. equivalent:false
1016
- * stays exit 0 — it is a legitimate not-yet-applied state the agent decides
1017
- * on (re-plan is the repair path).
1018
- */
1019
- async function cmdIntentReadback(file, environment, transport) {
1020
- const read = readIntentOrInvalid(file, transport);
1021
- if (read.kind === "exit")
1022
- return read.code;
1023
- const intent = read.intent;
1024
- let result;
1025
- try {
1026
- const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
1027
- const session = await context.sessions.session(environment);
1028
- const verification = dietIntentLive(await verifyIntentLive(session, intent), context);
1029
- const equivalent = verification.equivalent === true;
1030
- const ignoredCount = Number(verification.ignoredOutsideIntentCount ?? verification.ignoredOutsideIntent?.length ?? 0);
1031
- console.error(`intent readback:${equivalent ? "等价 ✓" : `${verification.differences.length} 项差异`}${ignoredCount > 0 ? `(意图外资源 ${ignoredCount} 项按所有权忽略)` : ""}`);
1032
- result = { status: "ok", ...verification };
1033
- }
1034
- catch (error) {
1035
- if (error instanceof ToolRefusalError)
1036
- result = error.payload;
1037
- else if (error instanceof WireError)
1038
- result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
1039
- else
1040
- result = { status: "error", message: error instanceof Error ? error.message : String(error) };
1041
- }
1042
- return emitResult(result, transport);
1043
- }
1044
- /**
1045
- * Ticket 119 — `omc intent apply <plan-file>`: plan integrity + baseline
1046
- * drift gates, then serial execution through the EXISTING apply_changes
1047
- * machinery (one master approval, per-step readback, first-failure stop).
1048
- * No write logic is forked here — the compiled changes drive the same
1049
- * governed pipeline `omc call` uses.
1050
- */
1051
- async function cmdIntentApply(planPath, options, transport) {
1052
- const emit = (result) => emitResult(result, transport);
1053
- const invalid = (blocker, message, code = "invalid-input") => emitInvalid(transport, { message, blocker, code });
1054
- let planText;
1055
- try {
1056
- planText = readIntentFile(intentConfigRoot(), planPath);
1057
- }
1058
- catch (fileError) {
1059
- // The plan for a large intent is externalized: `intent plan` returns
1060
- // `planFile` AND an artifactId. Callers read the id in the result and try
1061
- // `intent apply <artifactId>`, which used to fail with a bare "intent file
1062
- // does not exist" (bench40). Accept the artifact id directly, and otherwise
1063
- // name both recovery paths.
1064
- try {
1065
- planText = readArtifact(artifactRoot(), planPath).content.toString("utf8");
1066
- }
1067
- catch {
1068
- const hint = planPath.endsWith(".json") || planPath.includes("/") || planPath.includes("\\")
1069
- ? "plan 文件不存在或不在配置根内;plan 产物里有 planFile/artifactId 两种引用,直接给 planFile 或 artifactId 均可"
1070
- : `plan 找不到 "${planPath}"。大 plan 会外置——用 intent plan 结果里的 planFile 路径或 artifactId(可直接 \`omc intent apply <artifactId>\`);小 plan 内联在 result.plan,先落成 .json 再传入`;
1071
- return invalid(undefined, `${hint}(原错误:${fileError instanceof Error ? fileError.message : String(fileError)})`);
1072
- }
1073
- }
1074
- let parsed;
1075
- let autoPlanned = false;
1076
- try {
1077
- parsed = JSON.parse(planText);
1078
- }
1079
- catch (error) {
1080
- // r17 finding (two delegated agents, same wall): `intent apply <file>` read
1081
- // as "apply this intent file", but only a compiled plan JSON was accepted —
1082
- // each agent paid a help round-trip to discover plan --plan-out. Accept the
1083
- // SOURCE intent YAML directly: compile it in-process (same pipeline as
1084
- // `intent plan`) and apply the resulting plan.
1085
- const read = readIntentOrInvalid(planPath, transport);
1086
- if (read.kind !== "exit") {
1087
- try {
1088
- const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
1089
- const session = await context.sessions.session(options.env);
1090
- const baseline = await readBaseline(session, read.intent);
1091
- const compiled = compileIntent(read.intent, baseline);
1092
- parsed = buildPlan({
1093
- environment: session.environmentName,
1094
- environmentFingerprint: session.environmentFingerprint(),
1095
- compiler: INTENT_COMPILER_VERSION,
1096
- baseline,
1097
- intent: read.intent,
1098
- compiled,
1099
- });
1100
- autoPlanned = true;
1101
- }
1102
- catch (planError) {
1103
- return invalid(undefined, `文件既不是合法 plan JSON,也不能编译为意图计划:${planError instanceof Error ? planError.message : String(planError)}(若想先看计划再应用:intent plan <yaml> --env <env> --plan-out <plan.json>)`);
1104
- }
1105
- }
1106
- else {
1107
- return invalid(undefined, `plan 文件不是合法 JSON:${error instanceof Error ? error.message : String(error)};意图 YAML 请先 intent plan <yaml> --plan-out <plan.json>,或直接 intent apply <yaml> --env <env>(自动编译)`);
1108
- }
1109
- }
1110
- const verified = verifyPlan(parsed);
1111
- if (!verified.ok)
1112
- return invalid(verified.code, verified.reason);
1113
- const plan = verified.plan;
1114
- const planId = plan.planHash.slice(0, 12);
1115
- if (options.env !== undefined && options.env !== plan.environment)
1116
- return invalid("intent-plan-environment-mismatch", `--env ${options.env} 与 plan 环境不一致(plan 属于 ${plan.environment};plan 是不可变审批对象,不跨环境迁移)`);
1117
- if (options.dryRun === true) {
1118
- const toolCounts = new Map();
1119
- for (const change of plan.changes)
1120
- toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
1121
- // r25 finding: this hint taught `intent apply <planId>` — apply only
1122
- // accepts a plan FILE / JSON / the source YAML (planId is a hash for
1123
- // reference, not an apply input). Point at the real re-entry.
1124
- console.error(`intent apply <意图.yaml> --env ${plan.environment} --dry-run:${plan.changes.length} 项变更预览,零环境写入(apply 收文件/YAML,不收 planId)`);
1125
- return emit({
1126
- status: "dry-run",
1127
- wouldChange: plan.changes.length > 0,
1128
- planId,
1129
- changes: plan.changes,
1130
- skippedCount: plan.skipped.length,
1131
- riskClass: plan.riskClass,
1132
- preview: [...toolCounts.entries()].map(([tool, count]) => (count > 1 ? `${tool}×${count}` : tool)).join("、") || "无变更",
1133
- });
1134
- }
1135
- // The whole apply holds the same cross-process write lock `omc call`
1136
- // acquires per write — one writer per environment across every face.
1137
- const lock = await acquireWriteLock(plan.environment);
1138
- if (!lock.held) {
1139
- const conflict = {
1140
- status: "refused",
1141
- blocker: "write-lock-held",
1142
- environment: plan.environment,
1143
- owner: lock.owner,
1144
- recovery: lock.recovery,
1145
- message: lock.recovery[0],
1146
- };
1147
- console.error(`write lock conflict: ${JSON.stringify(lock.owner)}`);
1148
- return emit(conflict);
1149
- }
1150
- try {
1151
- const context = makeToolContext({ sessions: new SessionManager(new ConfigStore(defaultPaths())) });
1152
- const session = await context.sessions.session(plan.environment);
1153
- if (session.environmentFingerprint() !== plan.environmentFingerprint)
1154
- return emit({
1155
- status: "refused",
1156
- blocker: "intent-plan-environment-mismatch",
1157
- environment: session.environmentName,
1158
- message: "环境指纹与 plan 不一致——plan 不跨环境迁移;在目标环境重新 intent plan",
1159
- });
1160
- // Baseline drift gate (CONTEXT.md Configuration baseline): a fingerprint
1161
- // change after planning invalidates the plan and requires replanning.
1162
- const currentBaseline = await readBaseline(session, plan.intent);
1163
- if (baselineFingerprintOf(currentBaseline) !== plan.baselineFingerprint)
1164
- return emit({
1165
- status: "refused",
1166
- blocker: "intent-baseline-drifted",
1167
- environment: session.environmentName,
1168
- message: "plan 之后环境已变化(baseline fingerprint 不一致)——按配置基线契约,写入前必须重新 intent plan;绝不盲用过期 plan",
1169
- });
1170
- // Ticket 123 — the final confirmation: after the writes (or on an
1171
- // already-converged no-op) decode the authoritative live state back into
1172
- // intent shape and compare. A mismatch is NEVER reported as plain success
1173
- // (blocker intent-readback-mismatch + refusal exit); it means delivery is
1174
- // incomplete or drifted and the agent must re-plan, not retry blindly.
1175
- const confirmReadback = async (base) => {
1176
- if (options.verify === false)
1177
- return autoPlanned ? { ...base, plannedFrom: "intent-yaml" } : base;
1178
- let verification;
1179
- try {
1180
- const report = await verifyIntentLive(session, plan.intent);
1181
- verification = { ...dietIntentLive(report, context), status: report.equivalent === true ? "ok" : "verification-failed" };
1182
- }
1183
- catch (error) {
1184
- verification = { status: "error", equivalent: false, error: error instanceof Error ? error.message : String(error) };
1185
- }
1186
- if (verification.equivalent === true) {
1187
- console.error(`intent apply ${planId}:最终读回等价 ✓(意图 ${plan.intent.models.length} 个模型)`);
1188
- return { ...base, verification, ...(autoPlanned ? { plannedFrom: "intent-yaml" } : {}) };
1189
- }
1190
- console.error(`intent apply ${planId}:最终读回不等价——${Array.isArray(verification.differences) ? `${verification.differences.length} 项差异` : String(verification.error ?? "读回失败")};不得当作成功`);
1191
- return {
1192
- ...base,
1193
- status: "refused",
1194
- blocker: "intent-readback-mismatch",
1195
- verification,
1196
- message: "变更已执行,但最终权威读回与意图不等价:交付可能不完整或环境已漂移。按 differences 定位后重新 intent plan(不要盲重试)",
1197
- nextAction: "inspect verification.differences, then re-run intent plan against the live baseline",
1198
- };
1199
- };
1200
- if (plan.changes.length === 0) {
1201
- console.error(`intent apply ${planId}:零变更(环境已达标),无审批无写入`);
1202
- return emit(await confirmReadback({ status: "ok", planId, intentHash: plan.intentHash, applied: 0, skipped: plan.skipped, message: "环境已达标:零变更零审批" }));
1203
- }
1204
- const registry = buildToolRegistry();
1205
- const applyChanges = registry.find((definition) => definition.name === "apply_changes");
1206
- if (!applyChanges)
1207
- return emit({ status: "error", message: "apply_changes 工具不可用" });
1208
- const changes = plan.changes.map((change) => ({ tool: change.tool, args: change.args }));
1209
- const toolCounts = new Map();
1210
- for (const change of changes)
1211
- toolCounts.set(change.tool, (toolCounts.get(change.tool) ?? 0) + 1);
1212
- const summary = `intent apply ${planId}:${changes.length} 项变更(${[...toolCounts.entries()].map(([tool, count]) => (count > 1 ? `${tool}×${count}` : tool)).join("、")})`;
1213
- let result;
1214
- try {
1215
- result = (await applyChanges.handler(context, parseToolInput(applyChanges, {
1216
- changes,
1217
- environment: plan.environment,
1218
- summary,
1219
- ...(options.confirmProduction === true ? { confirmProduction: true } : {}),
1220
- }), applyChanges));
1221
- }
1222
- catch (error) {
1223
- if (error instanceof ToolInputError)
1224
- result = error.payload;
1225
- else if (error instanceof ToolRefusalError)
1226
- result = error.payload;
1227
- else if (error instanceof WireError)
1228
- result = { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message };
1229
- else
1230
- result = { status: "error", message: error instanceof Error ? error.message : String(error) };
1231
- }
1232
- if (result.blocker === "production-confirmation-required")
1233
- console.error(`intent apply ${planId}:目标环境是生产环境——确认这是高危操作后,用 --confirm-production 重跑 apply(一次确认覆盖全部 ${changes.length} 步)`);
1234
- else
1235
- console.error(`intent apply ${planId}:${String(result.status)}(${changes.length} 项变更)`);
1236
- const applied = Array.isArray(result.steps) ? result.steps.length : undefined;
1237
- // Context-cost (round 15): the intent ladder's per-step `performed`/`readback`
1238
- // is redundant with the final intent readback-equivalence (the authority), and
1239
- // for a complex model it is ~17 KB of the envelope (listview columns, field
1240
- // publishCodes, datarule rows). Emit the step IDENTITY only; the full per-step
1241
- // bodies stay in the package/progress artifact (result.readback.progressFile).
1242
- const steps = Array.isArray(result.steps)
1243
- ? result.steps.map((step) => ({ step: step.step, index: step.index, status: step.status }))
1244
- : result.steps;
1245
- const delivered = { ...result, steps, planId, intentHash: plan.intentHash, ...(applied !== undefined && result.status === "ok" ? { applied } : {}) };
1246
- // Only a genuinely delivered package earns the final readback; a partial /
1247
- // refused / uncertain outcome already reports its own status and must not
1248
- // be papered over by a second read.
1249
- return emit(result.status === "ok" ? await confirmReadback(delivered) : delivered);
1250
- }
1251
- finally {
1252
- await lock.release();
1253
- }
1254
- }
1255
755
  /**
1256
756
  * Ticket 122 — `omc help [topic]`: the methodology layer, reachable exactly
1257
757
  * like `git help <concept>`. The command list is the "what"; these topics are