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