in2-agent-server 0.20260821.185429
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 +369 -0
- package/agents/plan-requirement.md +32 -0
- package/dist/campfire/configItem.js +201 -0
- package/dist/campfire/configItemDescription.js +379 -0
- package/dist/campfire/deriveFromBundle.js +1013 -0
- package/dist/cli.js +465 -0
- package/dist/core/betaFeedback.js +493 -0
- package/dist/core/chapterInstructions.js +473 -0
- package/dist/core/claudeAssets.js +61 -0
- package/dist/core/gate.js +201 -0
- package/dist/core/ids.js +9 -0
- package/dist/core/install.js +60 -0
- package/dist/core/operatorQa.js +462 -0
- package/dist/core/orgCallMeter.js +178 -0
- package/dist/core/parcels.js +1235 -0
- package/dist/core/paths.js +105 -0
- package/dist/core/phases.js +225 -0
- package/dist/core/planFile.js +251 -0
- package/dist/core/planFragments.js +119 -0
- package/dist/core/readsLog.js +398 -0
- package/dist/core/registration.js +24 -0
- package/dist/core/result.js +38 -0
- package/dist/core/resume.js +233 -0
- package/dist/core/revisionChain.js +351 -0
- package/dist/core/richText.js +61 -0
- package/dist/core/semanticHash.js +89 -0
- package/dist/core/sessionOrg.js +60 -0
- package/dist/core/sfQuery.js +19 -0
- package/dist/core/spawn.js +182 -0
- package/dist/core/supervisorContract.js +230 -0
- package/dist/core/telemetry.js +1297 -0
- package/dist/core/telemetryContext.js +154 -0
- package/dist/core/telemetrySpool.js +244 -0
- package/dist/core/transcriptUpload.js +516 -0
- package/dist/core/update.js +363 -0
- package/dist/core/viewer.js +117 -0
- package/dist/core/workspace.js +92 -0
- package/dist/core/writeGrants.js +93 -0
- package/dist/core/writebackManifest.js +92 -0
- package/dist/direct/applyLoop.js +2516 -0
- package/dist/direct/batchReport.js +510 -0
- package/dist/direct/comparatorNoise.js +75 -0
- package/dist/direct/containers.js +85 -0
- package/dist/direct/contamination.js +221 -0
- package/dist/direct/crud.js +56 -0
- package/dist/direct/destructive.js +168 -0
- package/dist/direct/knownTokens.js +94 -0
- package/dist/direct/lint.js +382 -0
- package/dist/direct/metadataSchema.generated.json +1 -0
- package/dist/direct/metadataSchema.js +179 -0
- package/dist/direct/operatorPage.js +349 -0
- package/dist/direct/orgRepo.js +1531 -0
- package/dist/direct/orgRepoSpawnCount.js +21 -0
- package/dist/direct/plannedScope.js +119 -0
- package/dist/direct/preImageTargets.js +63 -0
- package/dist/direct/refVerify.js +191 -0
- package/dist/direct/registry.js +129 -0
- package/dist/direct/removals.js +262 -0
- package/dist/direct/rollback.js +1513 -0
- package/dist/direct/rollbackRun.js +1360 -0
- package/dist/direct/sessionProjection.js +126 -0
- package/dist/direct/snapshotComparability.js +165 -0
- package/dist/direct/sourceTree.js +113 -0
- package/dist/direct/unstatedChanges.js +106 -0
- package/dist/direct/waveAssembly.js +556 -0
- package/dist/direct/waveCounts.js +19 -0
- package/dist/direct/waveDerivation.js +152 -0
- package/dist/direct/waveGrade.js +185 -0
- package/dist/index.js +90 -0
- package/dist/orgread/customFieldSnapshot.js +48 -0
- package/dist/orgread/fieldMetadata.js +178 -0
- package/dist/orgread/genericSnapshot.js +61 -0
- package/dist/orgread/layoutSnapshot.js +151 -0
- package/dist/orgread/salesforceOrgReader.js +184 -0
- package/dist/orgread/snapshotTypes.js +9 -0
- package/dist/projection/bridge.js +26 -0
- package/dist/projection/diff.js +141 -0
- package/dist/projection/mapmd.js +213 -0
- package/dist/projection/nameAmbiguity.js +106 -0
- package/dist/projection/precedent.js +136 -0
- package/dist/projection/project.js +214 -0
- package/dist/projection/scope.js +110 -0
- package/dist/projection/types.js +5 -0
- package/dist/tools/analyze.js +322 -0
- package/dist/tools/auth.js +915 -0
- package/dist/tools/campfire.js +1742 -0
- package/dist/tools/closeBatch.js +154 -0
- package/dist/tools/closeText.js +35 -0
- package/dist/tools/direct.js +1613 -0
- package/dist/tools/feedback.js +318 -0
- package/dist/tools/operator.js +287 -0
- package/dist/tools/org.js +739 -0
- package/dist/tools/phase.js +249 -0
- package/dist/tools/projection.js +172 -0
- package/dist/tools/session.js +313 -0
- package/dist/tools/update.js +82 -0
- package/dist/tools/waves.js +793 -0
- package/package.json +48 -0
- package/reference/campfire-configuration-items-guide.md +710 -0
- package/reference/deployability-cribs.md +119 -0
- package/reference/operator-voice.md +141 -0
- package/reference/org-read-cribs.md +34 -0
- package/reference/scoped-dml-interim.md +68 -0
- package/reference/supervisor-playbook.md +313 -0
- package/reference/templates.md +233 -0
- package/skills/in2-delivery/SKILL.md +322 -0
- package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
- package/skills/in2-delivery/references/deployability-cribs.md +119 -0
- package/skills/in2-delivery/references/operator-voice.md +141 -0
- package/skills/in2-delivery/references/org-read-cribs.md +34 -0
- package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
- package/skills/in2-delivery/references/templates.md +233 -0
- package/skills/plan-requirement/SKILL.md +188 -0
- package/viewer/index.html +899 -0
- package/viewer/server.mjs +433 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// What `plan_waves` DERIVED, kept where the next call can check itself against it.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT THIS EXISTS FOR. `plan_waves` is advisory — deliberately, and that stays true: a
|
|
4
|
+
// supervisor who splits a wave further for a reason the planner cannot see is doing the right
|
|
5
|
+
// thing, and hand-authoring a wave is legitimate. What was missing is that NOTHING NOTICED when the
|
|
6
|
+
// submitted wave bore no relation to any derived one. The planner ran, returned N waves, and the
|
|
7
|
+
// supervisor then passed `direct_apply` a component list of its own; the two were never compared,
|
|
8
|
+
// so a hand-order that was a mistake looked exactly like a hand-order that was a judgement call.
|
|
9
|
+
//
|
|
10
|
+
// LIVE CASE (the Windows run): the supervisor hand-listed every component rather than lifting the
|
|
11
|
+
// planner's waves, and the wave count inflated. Each extra wave costs two deploys — a ~45s checkOnly
|
|
12
|
+
// and the real one — plus two projections. That is the single biggest lever on run time
|
|
13
|
+
// (`direct_apply` is 44.6% of all time spent inside tools) being pushed the wrong way, silently.
|
|
14
|
+
//
|
|
15
|
+
// SO: WARN, NEVER REFUSE. The submitted `waveComponents` are compared against every derived wave and
|
|
16
|
+
// the divergence is NAMED. It cannot block — the planner has no standing to overrule an operator who
|
|
17
|
+
// knows something it does not — but it can make the deviation visible at the moment it costs money,
|
|
18
|
+
// which is the one thing nothing did before.
|
|
19
|
+
//
|
|
20
|
+
// It is also FAIL-OPEN in every direction. No derivation on disk (the supervisor never ran
|
|
21
|
+
// plan_waves, or ran it before this shipped) means no claim: silence, not a warning. An unreadable
|
|
22
|
+
// file is the same. A wave that cannot compare itself to a plan still deploys — this is a note on a
|
|
23
|
+
// deploy, never a gate in front of one.
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { promises as fs } from "node:fs";
|
|
26
|
+
import { sessionDir, writeJsonAtomic } from "../core/workspace.js";
|
|
27
|
+
/** Where a session's derivation lives. One file per session — the planner's latest answer. */
|
|
28
|
+
export function derivationFile(sessionId) {
|
|
29
|
+
return path.join(sessionDir(sessionId), "analysis", "wave-derivation.json");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The comparison key. `path` is part of identity, not decoration: two revisions of one component
|
|
33
|
+
* are two different things to deploy, and a wave that swapped one for the other has diverged even
|
|
34
|
+
* though the type and name match.
|
|
35
|
+
*/
|
|
36
|
+
const keyOf = (c) => `${c.typeName}:${c.fullName}${c.path ? `@${c.path}` : ""}`;
|
|
37
|
+
/** Persist the planner's answer. FAIL-OPEN: a session that cannot record it still gets its plan. */
|
|
38
|
+
export async function writeWaveDerivation(sessionId, waves, groundedAgainst) {
|
|
39
|
+
try {
|
|
40
|
+
const derivation = {
|
|
41
|
+
schemaVersion: "wave-derivation-v1",
|
|
42
|
+
derivedAt: new Date().toISOString(),
|
|
43
|
+
waves,
|
|
44
|
+
groundedAgainst
|
|
45
|
+
};
|
|
46
|
+
await writeJsonAtomic(derivationFile(sessionId), derivation);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Bookkeeping never blocks a plan. The consequence is one missing warning later, not a failure.
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Read it back, or null for "no claim to make" — never a throw, never a partial answer. */
|
|
55
|
+
export async function readWaveDerivation(sessionId) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(await fs.readFile(derivationFile(sessionId), "utf8"));
|
|
58
|
+
if (parsed?.schemaVersion !== "wave-derivation-v1" || !Array.isArray(parsed.waves))
|
|
59
|
+
return null;
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compare one submitted wave against the whole derivation.
|
|
68
|
+
*
|
|
69
|
+
* "Corresponds to a derived wave" is deliberately generous: the nearest wave is the one sharing the
|
|
70
|
+
* most components, and an exact match on that wave's contents is a clean pass. Everything else is
|
|
71
|
+
* DESCRIBED rather than judged — which components the planner never ordered at all, which it put in
|
|
72
|
+
* other waves, and which of the nearest wave's own members were left out. That is the information a
|
|
73
|
+
* supervisor needs to say "yes, I meant that" in one line, or to notice they did not.
|
|
74
|
+
*
|
|
75
|
+
* A submission that touches NO derived component at all gets `nearestWave: null` — the honest
|
|
76
|
+
* answer, and the shape of the live defect: a hand-listed batch that corresponds to nothing.
|
|
77
|
+
*/
|
|
78
|
+
export function compareToDerivation(submitted, derivation) {
|
|
79
|
+
const want = new Set(submitted.map(keyOf));
|
|
80
|
+
// Where the planner put each component. Built over the whole derivation, because "you deployed
|
|
81
|
+
// this early" is only answerable by knowing which OTHER wave it belongs to.
|
|
82
|
+
const waveOf = new Map();
|
|
83
|
+
derivation.waves.forEach((wave, i) => {
|
|
84
|
+
for (const c of wave)
|
|
85
|
+
if (!waveOf.has(keyOf(c)))
|
|
86
|
+
waveOf.set(keyOf(c), i);
|
|
87
|
+
});
|
|
88
|
+
let nearest = -1;
|
|
89
|
+
let bestOverlap = 0;
|
|
90
|
+
derivation.waves.forEach((wave, i) => {
|
|
91
|
+
const overlap = wave.filter((c) => want.has(keyOf(c))).length;
|
|
92
|
+
if (overlap > bestOverlap) {
|
|
93
|
+
bestOverlap = overlap;
|
|
94
|
+
nearest = i;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
const unplanned = [...want].filter((k) => !waveOf.has(k)).sort();
|
|
98
|
+
if (nearest < 0) {
|
|
99
|
+
return {
|
|
100
|
+
nearestWave: null,
|
|
101
|
+
unplanned,
|
|
102
|
+
missingFromWave: [],
|
|
103
|
+
fromOtherWaves: [...want]
|
|
104
|
+
.filter((k) => waveOf.has(k))
|
|
105
|
+
.map((k) => ({ component: k, derivedWave: waveOf.get(k) + 1 }))
|
|
106
|
+
.sort((a, b) => a.component.localeCompare(b.component)),
|
|
107
|
+
matches: false
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const nearestKeys = new Set(derivation.waves[nearest].map(keyOf));
|
|
111
|
+
const missingFromWave = [...nearestKeys].filter((k) => !want.has(k)).sort();
|
|
112
|
+
const fromOtherWaves = [...want]
|
|
113
|
+
.filter((k) => !nearestKeys.has(k) && waveOf.has(k))
|
|
114
|
+
.map((k) => ({ component: k, derivedWave: waveOf.get(k) + 1 }))
|
|
115
|
+
.sort((a, b) => a.component.localeCompare(b.component));
|
|
116
|
+
return {
|
|
117
|
+
nearestWave: nearest + 1,
|
|
118
|
+
unplanned,
|
|
119
|
+
missingFromWave,
|
|
120
|
+
fromOtherWaves,
|
|
121
|
+
matches: unplanned.length === 0 && missingFromWave.length === 0 && fromOtherWaves.length === 0
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The warning text, or null when there is nothing to say.
|
|
126
|
+
*
|
|
127
|
+
* Written to be actionable in one read: what diverged, what it costs, and the fact that proceeding
|
|
128
|
+
* is allowed. The last part is not a courtesy — a warning that reads like a refusal gets worked
|
|
129
|
+
* around, and working around this one means hand-copying files past the ownership checks.
|
|
130
|
+
*/
|
|
131
|
+
export function renderDivergence(report, derivation) {
|
|
132
|
+
if (report.matches)
|
|
133
|
+
return null;
|
|
134
|
+
const lines = [];
|
|
135
|
+
if (report.nearestWave === null) {
|
|
136
|
+
lines.push(`WAVE NOT DERIVED — this component list corresponds to NO wave plan_waves derived (it ordered ${derivation.waves.length} wave(s) at ${derivation.derivedAt}).`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
lines.push(`WAVE DIVERGES FROM THE DERIVED PLAN — closest is derived wave ${report.nearestWave} of ${derivation.waves.length} (derived ${derivation.derivedAt}), and this submission is not it:`);
|
|
140
|
+
}
|
|
141
|
+
if (report.unplanned.length > 0) {
|
|
142
|
+
lines.push(` ${report.unplanned.length} component(s) plan_waves ordered into NO wave — check they are in a plan.json and were not left unplaced or refused:`, ...report.unplanned.map((c) => ` ${c}`));
|
|
143
|
+
}
|
|
144
|
+
if (report.fromOtherWaves.length > 0) {
|
|
145
|
+
lines.push(` ${report.fromOtherWaves.length} component(s) the planner put in a DIFFERENT wave — merging across a dependency edge is the one merge that is never safe:`, ...report.fromOtherWaves.map((f) => ` ${f.component} (derived wave ${f.derivedWave})`));
|
|
146
|
+
}
|
|
147
|
+
if (report.missingFromWave.length > 0) {
|
|
148
|
+
lines.push(` ${report.missingFromWave.length} component(s) of derived wave ${report.nearestWave} are NOT in this submission — they will need a wave of their own:`, ...report.missingFromWave.map((c) => ` ${c}`));
|
|
149
|
+
}
|
|
150
|
+
lines.push("", "NOT A REFUSAL, and nothing here has been changed: hand-ordering is legitimate and the planner cannot see", "every reason to split. What it can see is the cost — every extra wave is two org round-trips (a ~45s", "checkOnly and the deploy) plus two projections, and a batch listed by hand rather than lifted from", "plan_waves is how a 4-wave plan becomes a 7-wave run. If this split was deliberate, say so when you", "report the wave. If it was not, re-run plan_waves and pass its wave list through unedited.");
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** `edit|CustomField:PV_Asset__c.PV_Critical__c` — the join key coverage is decided on. */
|
|
4
|
+
function scopeKey(op, typeName, fullName) {
|
|
5
|
+
return `${op}|${typeName}:${fullName}`;
|
|
6
|
+
}
|
|
7
|
+
const DELIVERED = new Set(["DIFF_VERIFIED", "DELIVERED_WITH_DRIFT"]);
|
|
8
|
+
/**
|
|
9
|
+
* Read every bundle this session left on disk.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately the SAME directory scan both graders used separately, so a report that disagreed
|
|
12
|
+
* with the terminal event about how many waves delivered is no longer expressible.
|
|
13
|
+
*/
|
|
14
|
+
export function readWaveBundles(directDir) {
|
|
15
|
+
if (!existsSync(directDir))
|
|
16
|
+
return [];
|
|
17
|
+
const bundles = [];
|
|
18
|
+
for (const entry of readdirSync(directDir, { withFileTypes: true })) {
|
|
19
|
+
if (!entry.isDirectory())
|
|
20
|
+
continue;
|
|
21
|
+
const bundle = path.join(directDir, entry.name);
|
|
22
|
+
const verdictFile = path.join(bundle, "verdict.json");
|
|
23
|
+
if (!existsSync(verdictFile))
|
|
24
|
+
continue;
|
|
25
|
+
let verdict;
|
|
26
|
+
try {
|
|
27
|
+
verdict = String(JSON.parse(readFileSync(verdictFile, "utf8")).verdict ?? "UNKNOWN");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// An unreadable bundle is not a verdict; skip rather than mis-grade the batch.
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
bundles.push({ wave: entry.name, verdict, ...readRequest(bundle) });
|
|
34
|
+
}
|
|
35
|
+
return bundles.sort((a, b) => a.wave.localeCompare(b.wave));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `dryRunOnly` and the touched scope, from the request the caller actually made.
|
|
39
|
+
*
|
|
40
|
+
* FAILS CLOSED on an unreadable request: `dryRunOnly: false` means the bundle grades as a real
|
|
41
|
+
* wave, so a refusal surfaces rather than being written off as practice. The two directions are not
|
|
42
|
+
* symmetric — a wrong `true` hides a wave that did not deliver, which is the failure the grade
|
|
43
|
+
* exists to catch, while a wrong `false` costs one over-reported wave a reader can check.
|
|
44
|
+
*/
|
|
45
|
+
function readRequest(bundle) {
|
|
46
|
+
try {
|
|
47
|
+
const request = JSON.parse(readFileSync(path.join(bundle, "request.json"), "utf8"));
|
|
48
|
+
return {
|
|
49
|
+
dryRunOnly: request.dryRunOnly === true,
|
|
50
|
+
scope: [
|
|
51
|
+
...(request.edits ?? []).map((e) => scopeKey("edit", e.typeName, e.fullName)),
|
|
52
|
+
...(request.deletes ?? []).map((d) => scopeKey("delete", d.typeName, d.fullName)),
|
|
53
|
+
...(request.facetUpdates ?? []).map((f) => scopeKey("facet", "CustomObject", f.objectApiName))
|
|
54
|
+
]
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return { dryRunOnly: false, scope: null };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Grade the batch from its bundles. Pure — every input is already on disk and read.
|
|
63
|
+
*
|
|
64
|
+
* THE RULE FOR A BLOCKED PRACTICE RUN, which is the only genuinely contestable call here.
|
|
65
|
+
*
|
|
66
|
+
* A refused practice check is, in a normal run, exactly what a human should look at before the real
|
|
67
|
+
* wave — it is the mechanism working. But in PV Arm B it was a DELIBERATE demonstration of the
|
|
68
|
+
* block, and the two fields the org refused to delete there were deleted for real by `w4` minutes
|
|
69
|
+
* later. Blessing all rejected dry runs would hide the first case; counting all of them would keep
|
|
70
|
+
* reporting the second as unfinished business that finished.
|
|
71
|
+
*
|
|
72
|
+
* So the batch's own evidence decides: a blocked practice run stops being attention-worthy when a
|
|
73
|
+
* DELIVERING wave in the same batch performed the SAME OPERATION on EVERY component it named — the
|
|
74
|
+
* org accepted, and verified, precisely what it had refused in practice.
|
|
75
|
+
*
|
|
76
|
+
* Four limits, stated rather than papered over:
|
|
77
|
+
* - This is a SCOPE claim, not a causal one. It establishes that the same operation on the same
|
|
78
|
+
* components succeeded in this batch; it does not prove the reason for the refusal was
|
|
79
|
+
* understood. That is why a covered run is still REPORTED, by name, with its covering waves
|
|
80
|
+
* named too — the grade drops the alarm, the report never drops the fact.
|
|
81
|
+
* - The scope key is `(operation, typeName:fullName)`, which is COMPONENT identity — and under the
|
|
82
|
+
* component revision model one batch may deliver two STATES of one component, in two waves, with
|
|
83
|
+
* the same key. So a practice run blocked on an early state of a component is cleared by a later
|
|
84
|
+
* state of it delivering, even though the two waves carried different files. That is the widest
|
|
85
|
+
* this claim reaches, and it is ACCEPTED rather than fixed: narrowing the key to a revision would
|
|
86
|
+
* mean importing the revision model into a module that is deliberately synchronous and
|
|
87
|
+
* dependency-free (`finalizeBatch` may run inside a signal handler, where async work does not
|
|
88
|
+
* survive), to sharpen an alarm that is already only advisory and already names its own evidence.
|
|
89
|
+
* A reader who wants the stronger claim has the covering wave's name and the delivery log.
|
|
90
|
+
* - It makes NO temporal claim. "Later" is not derivable here: bundle directories sort by label,
|
|
91
|
+
* not by time, and the delivery log carries an `orderingUnreliable` flag precisely because
|
|
92
|
+
* position is not evidence of when. Same-batch coverage needs no ordering to be true.
|
|
93
|
+
* - Operation kind must MATCH. A practice run refused a delete; an unrelated later edit of the
|
|
94
|
+
* same field is not that delete succeeding.
|
|
95
|
+
*/
|
|
96
|
+
export function gradeWaves(bundles) {
|
|
97
|
+
const delivering = bundles.filter((b) => !b.dryRunOnly && DELIVERED.has(b.verdict));
|
|
98
|
+
const deliveredScope = new Map();
|
|
99
|
+
for (const b of delivering) {
|
|
100
|
+
for (const key of b.scope ?? []) {
|
|
101
|
+
const waves = deliveredScope.get(key) ?? [];
|
|
102
|
+
waves.push(b.wave);
|
|
103
|
+
deliveredScope.set(key, waves);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const waves = bundles.map((bundle) => {
|
|
107
|
+
if (!bundle.dryRunOnly) {
|
|
108
|
+
const delivered = DELIVERED.has(bundle.verdict);
|
|
109
|
+
return {
|
|
110
|
+
...bundle,
|
|
111
|
+
classification: delivered ? "delivered" : "problem",
|
|
112
|
+
resolvedBy: null,
|
|
113
|
+
needsAttention: !delivered
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (bundle.verdict === "VALIDATED") {
|
|
117
|
+
return { ...bundle, classification: "practice-accepted", resolvedBy: null, needsAttention: false };
|
|
118
|
+
}
|
|
119
|
+
// Every other dry-run verdict is a BLOCK — the org refused it, our lint refused it, or it is a
|
|
120
|
+
// verdict this vocabulary does not know. The unknown case lands here rather than with the
|
|
121
|
+
// accepted ones on purpose: a verdict nobody recognises is not evidence that anything passed.
|
|
122
|
+
const resolvedBy = coveringWaves(bundle.scope, deliveredScope);
|
|
123
|
+
return {
|
|
124
|
+
...bundle,
|
|
125
|
+
classification: "practice-blocked",
|
|
126
|
+
resolvedBy,
|
|
127
|
+
needsAttention: resolvedBy === null
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
const deliveredWaves = waves.filter((w) => w.classification === "delivered").length;
|
|
131
|
+
const problemWaves = waves.filter((w) => w.needsAttention).length;
|
|
132
|
+
const practiceRuns = waves.length - deliveredWaves - problemWaves;
|
|
133
|
+
return {
|
|
134
|
+
result: gradeResult(waves.length, deliveredWaves, problemWaves),
|
|
135
|
+
waves,
|
|
136
|
+
deliveredWaves,
|
|
137
|
+
problemWaves,
|
|
138
|
+
practiceRuns,
|
|
139
|
+
reason: gradeReason(waves.length, deliveredWaves, problemWaves, practiceRuns)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** The waves that covered every component this bundle named, or null when any went uncovered. */
|
|
143
|
+
function coveringWaves(scope, deliveredScope) {
|
|
144
|
+
// A scope that could not be read establishes nothing, and an EMPTY scope is vacuously covered —
|
|
145
|
+
// both would silently clear a block. Neither is allowed to.
|
|
146
|
+
if (!scope || scope.length === 0)
|
|
147
|
+
return null;
|
|
148
|
+
const covering = new Set();
|
|
149
|
+
for (const key of scope) {
|
|
150
|
+
const waves = deliveredScope.get(key);
|
|
151
|
+
if (!waves)
|
|
152
|
+
return null;
|
|
153
|
+
for (const wave of waves)
|
|
154
|
+
covering.add(wave);
|
|
155
|
+
}
|
|
156
|
+
return [...covering].sort();
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* COMPLETE / NEEDS_ATTENTION / FAILED / NO_WAVES.
|
|
160
|
+
*
|
|
161
|
+
* `NO_WAVES` covers two runs that look different and mean the same thing: one that never deployed,
|
|
162
|
+
* and one whose only bundles were accepted practice runs. Neither changed the org, so neither has a
|
|
163
|
+
* DELIVERY result to state, and `COMPLETE` on a run that delivered nothing would call a batch
|
|
164
|
+
* finished that never started. `finalizeBatch` sends no terminal event in that case, exactly as it
|
|
165
|
+
* has always done for a read-only run.
|
|
166
|
+
*/
|
|
167
|
+
function gradeResult(total, delivered, problems) {
|
|
168
|
+
if (total === 0)
|
|
169
|
+
return "NO_WAVES";
|
|
170
|
+
if (problems === 0)
|
|
171
|
+
return delivered === 0 ? "NO_WAVES" : "COMPLETE";
|
|
172
|
+
return delivered === 0 ? "FAILED" : "NEEDS_ATTENTION";
|
|
173
|
+
}
|
|
174
|
+
/** The counted status line — never a narrative, never an org's error text, bounded well under 200. */
|
|
175
|
+
function gradeReason(total, delivered, problems, practice) {
|
|
176
|
+
const practiceNote = practice > 0 ? `; ${practice} practice run(s), none needing review` : "";
|
|
177
|
+
if (total === 0)
|
|
178
|
+
return "no wave verdicts on disk";
|
|
179
|
+
if (problems === 0) {
|
|
180
|
+
return delivered === 0
|
|
181
|
+
? `no wave delivered — ${practice} practice run(s) only, all accepted`
|
|
182
|
+
: `all ${delivered} wave(s) delivered${practiceNote}`;
|
|
183
|
+
}
|
|
184
|
+
return `${problems} of ${total} wave(s) need review${practiceNote}`;
|
|
185
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { log } from "./core/result.js";
|
|
5
|
+
import { Telemetry } from "./core/telemetry.js";
|
|
6
|
+
import { supervisorInstructions } from "./core/supervisorContract.js";
|
|
7
|
+
import { betaEnabled } from "./core/betaFeedback.js";
|
|
8
|
+
import { REPO_ROOT, WORKSPACE_ROOT } from "./core/paths.js";
|
|
9
|
+
import { campfireAllowlistedOrgIds, campfireWriteEnabled, directAllowlistOpen, directWriteEnabled } from "./core/gate.js";
|
|
10
|
+
import { activeGrants } from "./core/writeGrants.js";
|
|
11
|
+
import { registerSessionTools } from "./tools/session.js";
|
|
12
|
+
import { registerActivationTools, registerAuthTools } from "./tools/auth.js";
|
|
13
|
+
import { registerOperatorTools } from "./tools/operator.js";
|
|
14
|
+
import { registerCampfireTools } from "./tools/campfire.js";
|
|
15
|
+
import { registerOrgTools } from "./tools/org.js";
|
|
16
|
+
import { registerProjectionTools } from "./tools/projection.js";
|
|
17
|
+
import { registerDirectTools } from "./tools/direct.js";
|
|
18
|
+
import { registerUpdateTools } from "./tools/update.js";
|
|
19
|
+
import { registerAnalyzeTools } from "./tools/analyze.js";
|
|
20
|
+
import { registerWaveTools } from "./tools/waves.js";
|
|
21
|
+
import { registerPhaseTools } from "./tools/phase.js";
|
|
22
|
+
import { registerCloseBatchTools } from "./tools/closeBatch.js";
|
|
23
|
+
import { registerFeedbackTools } from "./tools/feedback.js";
|
|
24
|
+
import { instrumentRegistration, registeredToolNames } from "./core/registration.js";
|
|
25
|
+
import { installClaudeAssets } from "./core/claudeAssets.js";
|
|
26
|
+
import { installedVersion, isNpmInstall } from "./core/install.js";
|
|
27
|
+
const server = new McpServer({ name: "cc-mcp-server", version: "0.1.0" },
|
|
28
|
+
// Shipped to the model on connect regardless of cwd — the only channel that does not depend on
|
|
29
|
+
// the operator opening a particular directory or describing the workflow themselves. Carries the
|
|
30
|
+
// beta feedback section only when CC_MCP_BETA is on; an install that is not in beta ships a
|
|
31
|
+
// contract with no trace of the channel.
|
|
32
|
+
{ instructions: supervisorInstructions() });
|
|
33
|
+
// Telemetry wraps registerTool ONCE, before any registration, so every tool — present and
|
|
34
|
+
// future — is timed without each module having to remember. Never throws; CC_MCP_TELEMETRY=0
|
|
35
|
+
// disables it.
|
|
36
|
+
const telemetry = new Telemetry();
|
|
37
|
+
instrumentRegistration(server, telemetry);
|
|
38
|
+
// Default surface = the direct working tier and everything it needs. Fixed for the life of the
|
|
39
|
+
// process — the tool surface no longer swaps live (the hot-swap updater is gone with the move to
|
|
40
|
+
// npm distribution; an update lands for the NEXT conversation).
|
|
41
|
+
registerSessionTools(server);
|
|
42
|
+
registerAuthTools(server);
|
|
43
|
+
registerActivationTools(server);
|
|
44
|
+
registerOperatorTools(server);
|
|
45
|
+
registerCampfireTools(server);
|
|
46
|
+
registerOrgTools(server);
|
|
47
|
+
registerProjectionTools(server);
|
|
48
|
+
registerDirectTools(server);
|
|
49
|
+
registerUpdateTools(server);
|
|
50
|
+
registerAnalyzeTools(server);
|
|
51
|
+
registerWaveTools(server);
|
|
52
|
+
registerPhaseTools(server);
|
|
53
|
+
registerCloseBatchTools(server);
|
|
54
|
+
// Registers nothing unless CC_MCP_BETA is on — the flag lives inside the registrar.
|
|
55
|
+
registerFeedbackTools(server);
|
|
56
|
+
// Keep ~/.claude/skills and ~/.claude/agents in step with THIS build. Best-effort by contract and
|
|
57
|
+
// awaited before the transport connects, so a session never races its own instructions.
|
|
58
|
+
await installClaudeAssets();
|
|
59
|
+
log(`repo root: ${REPO_ROOT}`);
|
|
60
|
+
log(`workspace root: ${WORKSPACE_ROOT}`);
|
|
61
|
+
log(`telemetry: ${process.env.CC_MCP_TELEMETRY === "0" ? "disabled (CC_MCP_TELEMETRY=0)" : `run ${telemetry.runId}`}`);
|
|
62
|
+
// Which orgs this install can actually write to is the highest-consequence fact about it, so it is
|
|
63
|
+
// stated plainly at every start rather than left to be inferred from env vars.
|
|
64
|
+
const grants = await activeGrants().catch(() => []);
|
|
65
|
+
log(`direct write: ${directWriteEnabled()
|
|
66
|
+
? directAllowlistOpen()
|
|
67
|
+
? "ENABLED, allowlist WIDE OPEN (CC_MCP_DIRECT_ALLOWLIST=*) — every authenticated org is writable; consultant org still refused. INTERNAL DEV ONLY, must not ship."
|
|
68
|
+
: "ENABLED, authority from activation grants + per-org allowlist"
|
|
69
|
+
: "closed (default)"}`);
|
|
70
|
+
log(grants.length > 0
|
|
71
|
+
? `activation grants: ${grants.map((g) => `${g.orgId} (${g.alias}, expires ${g.expiresAt.slice(0, 10)})`).join("; ")}`
|
|
72
|
+
: "activation grants: none — no org is writable by grant until a Campfire code is redeemed with activate_session");
|
|
73
|
+
// Stated at start for the same reason as direct write, and for one more: this gate is checked at
|
|
74
|
+
// the END of a run. A closed Campfire gate costs nothing until writeback, and by then the session
|
|
75
|
+
// has spent half an hour and the fix (different server env) needs a restart the operator cannot
|
|
76
|
+
// perform. Saying it here turns a late, expensive refusal into something the supervisor can card
|
|
77
|
+
// on turn one — while re-registering is still cheap.
|
|
78
|
+
log(`campfire write: ${campfireWriteEnabled()
|
|
79
|
+
? `ENABLED for allowlisted consultant org(s) [${campfireAllowlistedOrgIds().join(", ")}]`
|
|
80
|
+
: "CLOSED (default) — writeback will refuse CAMPFIRE_WRITE_NOT_AUTHORIZED at the END of the run. Set CC_MCP_CAMPFIRE_WRITE=1 and restart if this session is expected to write Configuration Items back."}`);
|
|
81
|
+
log(`version: ${installedVersion()}${isNpmInstall() ? " (npm — update_server / in2 update install the next version)" : " (checkout — updates come from git, not npm)"}`);
|
|
82
|
+
// Beta mode changes the tool surface AND the operating contract, so it is stated at every start for
|
|
83
|
+
// the same reason direct write is: a machine in beta must never be mistakable for one that is not.
|
|
84
|
+
// The announcement itself (what leaves, and how to stop it) is announceBeta's, called from Telemetry
|
|
85
|
+
// beside the other content-channel lines; this is the one-word status beside the other capabilities.
|
|
86
|
+
log(`beta: ${betaEnabled() ? "ON (CC_MCP_BETA) — report_feedback and record_operator_feedback are registered" : "off (default)"}`);
|
|
87
|
+
log(`tool surface: direct tier only (${registeredToolNames().length} tools)`);
|
|
88
|
+
const transport = new StdioServerTransport();
|
|
89
|
+
await server.connect(transport);
|
|
90
|
+
log("cc-mcp-server ready (stdio)");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Dedicated CustomField snapshot. Ported from sf-config-verifier
|
|
2
|
+
// (src/snapshots/customFieldSnapshot.ts).
|
|
3
|
+
//
|
|
4
|
+
// Carries EVERY facet the Metadata API returned, coercing the typed ones at the READ boundary so a
|
|
5
|
+
// comparison never has to care that the API hands back XML leaves as strings.
|
|
6
|
+
//
|
|
7
|
+
// It used to carry a ~16-facet allowlist instead, and that was a defect, not a design. Any facet an
|
|
8
|
+
// author stated that fell outside the list — <unique>, <externalId>, <caseSensitive>, and 40-odd
|
|
9
|
+
// others the schema knows — was dropped by THIS reader before the comparison ever ran, so the
|
|
10
|
+
// intent check reported it as intended-but-absent: false drift on a field the org had stored
|
|
11
|
+
// exactly as asked. Worse, the evidence could not distinguish the two explanations, because a facet
|
|
12
|
+
// our reader discarded and a facet the org omitted look identical downstream. A snapshot that
|
|
13
|
+
// silently narrows what it observed cannot be the substrate for a drift verdict.
|
|
14
|
+
//
|
|
15
|
+
// The shared normalizer does the coercion (fieldMetadata.normalizeCustomFieldMetadata — the same
|
|
16
|
+
// one the generic CustomField path and the intent side already use, so all three compare
|
|
17
|
+
// like-with-like); this reader adds only what is specific to reading ONE named field.
|
|
18
|
+
import { normalizeCustomFieldMetadata, valueSetDefaultValue } from "./fieldMetadata.js";
|
|
19
|
+
import { snapshot } from "./snapshotTypes.js";
|
|
20
|
+
export async function readCustomFieldSnapshot(org, fullName) {
|
|
21
|
+
// Upstream destructured `[result]` and dereferenced it directly, which throws if the reader hands
|
|
22
|
+
// back an empty array. cc-mcp compiles with noUncheckedIndexedAccess and surfaced it; an empty
|
|
23
|
+
// response is treated as not-found, which is what the caller can actually act on.
|
|
24
|
+
const rows = await org.metadataRead("CustomField", [fullName]);
|
|
25
|
+
const result = rows[0] ?? { typeName: "CustomField", fullName, found: false, errors: ["metadataRead returned no rows"] };
|
|
26
|
+
const normalized = result.found ? normalizeCustomFieldMetadata(result.metadata) : null;
|
|
27
|
+
let metadata = null;
|
|
28
|
+
if (normalized) {
|
|
29
|
+
// The wire carries the UNQUALIFIED field name (`CF63_Notes__c`); this reader answers for the
|
|
30
|
+
// name it was asked about (`CF63_Intake__c.CF63_Notes__c`), which is the key every caller holds.
|
|
31
|
+
metadata = { ...normalized, fullName };
|
|
32
|
+
const defaultValue = asDefaultValue(normalized.defaultValue) ?? valueSetDefaultValue(normalized.valueSet);
|
|
33
|
+
if (defaultValue !== undefined)
|
|
34
|
+
metadata.defaultValue = defaultValue;
|
|
35
|
+
}
|
|
36
|
+
const state = { metadata };
|
|
37
|
+
return snapshot("customField", "CustomField", fullName, "metadata.read", state, result.errors);
|
|
38
|
+
}
|
|
39
|
+
// EXT-2040: the CustomField <defaultValue> element is a formula-syntax STRING (e.g. "false" for a
|
|
40
|
+
// checkbox default-unchecked, `"West"` for a text literal, TODAY() for a date). Some XML parsers
|
|
41
|
+
// coerce a bare `<defaultValue>false</defaultValue>` to a boolean and `<defaultValue>5</defaultValue>`
|
|
42
|
+
// to a number (the same coercion class) — normalize both back to the string form so a comparison on
|
|
43
|
+
// the stated literal matches byte-for-byte. A Picklist carries NO top-level <defaultValue>; its
|
|
44
|
+
// default is derived from the valueSet per-value <default> flag (valueSetDefaultValue, at the call
|
|
45
|
+
// site above).
|
|
46
|
+
function asDefaultValue(v) {
|
|
47
|
+
return typeof v === "string" ? v : typeof v === "boolean" || typeof v === "number" ? String(v) : undefined;
|
|
48
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CustomField metadata normalization — the record of how Salesforce's Metadata API ACTUALLY
|
|
3
|
+
* serializes a field, as opposed to what you asked it to store.
|
|
4
|
+
*
|
|
5
|
+
* Ported verbatim from sf-config-verifier (src/snapshots/customFieldMetadata.ts). Every rule below
|
|
6
|
+
* was caught against a live org, usually as a family that scored zero after a byte-perfect deploy.
|
|
7
|
+
* The EXT ids are load-bearing: they are the only record of why each line exists, so keep them with
|
|
8
|
+
* the code they explain.
|
|
9
|
+
*
|
|
10
|
+
* This is not cosmetic. direct/applyLoop.ts compares pre- and post-deploy reads through these
|
|
11
|
+
* functions, so a missing rule here does not produce a slightly-off report — it produces a false
|
|
12
|
+
* failure on correct work, or a false green on incorrect work.
|
|
13
|
+
*
|
|
14
|
+
* Applied by BOTH the dedicated customField snapshot and the generic-metadata path
|
|
15
|
+
* (picklistDependency / formula / rollupSummary / lookupRelationship / masterDetailRelationship all
|
|
16
|
+
* resolve to a CustomField). Keeping it in one place fixes F11: a picklist value set was normalized
|
|
17
|
+
* only on the dedicated path, so an EXT-531-style `picklistDependency` family (which routes through
|
|
18
|
+
* the generic verifier) saw a raw `<valueSet>` and failed `$.metadata.valueSet.values` — a
|
|
19
|
+
* false-fail.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Normalize a parsed `<valueSet>` so callers can read a flat `valueSet.values: string[]` (the
|
|
23
|
+
* inline picklist value API names, in document order) instead of navigating
|
|
24
|
+
* `valueSetDefinition.value[].fullName` — which is also brittle because a single <value> parses to
|
|
25
|
+
* an object, not an array. The raw structure is preserved; `values`, normalized `restricted`, and a
|
|
26
|
+
* global-value-set `valueSetName` are added on top. Returns the input unchanged for non-picklist
|
|
27
|
+
* (non-object) values.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeValueSet(raw) {
|
|
30
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
31
|
+
return raw;
|
|
32
|
+
const vs = raw;
|
|
33
|
+
const out = { ...vs };
|
|
34
|
+
if (typeof vs.valueSetName === "string")
|
|
35
|
+
out.valueSetName = vs.valueSetName;
|
|
36
|
+
if (vs.restricted !== undefined)
|
|
37
|
+
out.restricted = vs.restricted === true || vs.restricted === "true";
|
|
38
|
+
const def = vs.valueSetDefinition;
|
|
39
|
+
const rawValues = def?.value;
|
|
40
|
+
const valueList = rawValues === undefined ? [] : Array.isArray(rawValues) ? rawValues : [rawValues];
|
|
41
|
+
const values = valueList
|
|
42
|
+
.map((v) => (v && typeof v === "object" ? v.fullName : v))
|
|
43
|
+
.filter((v) => typeof v === "string");
|
|
44
|
+
if (values.length > 0)
|
|
45
|
+
out.values = values;
|
|
46
|
+
// D5 (registry v11, live-caught EXT-15346): canonicalize the dependent-picklist valid-for matrix
|
|
47
|
+
// so a valueSettings comparison (sorted [{value, controllingFieldValues[]}]) can hold under a
|
|
48
|
+
// strict deep equality. Wire shape per entry: {valueName, controllingFieldValue: string|string[]}
|
|
49
|
+
// with the usual single-element object/array collapse. Entries without a valueName are dropped
|
|
50
|
+
// (never guessed).
|
|
51
|
+
const rawSettings = vs.valueSettings;
|
|
52
|
+
if (rawSettings !== undefined) {
|
|
53
|
+
const settingsList = Array.isArray(rawSettings) ? rawSettings : [rawSettings];
|
|
54
|
+
const canonical = settingsList
|
|
55
|
+
.flatMap((entry) => {
|
|
56
|
+
if (!entry || typeof entry !== "object")
|
|
57
|
+
return [];
|
|
58
|
+
const record = entry;
|
|
59
|
+
if (typeof record.valueName !== "string")
|
|
60
|
+
return [];
|
|
61
|
+
const rawControlling = record.controllingFieldValue;
|
|
62
|
+
const controllingFieldValues = (rawControlling === undefined ? [] : Array.isArray(rawControlling) ? rawControlling : [rawControlling])
|
|
63
|
+
.filter((c) => typeof c === "string")
|
|
64
|
+
.sort();
|
|
65
|
+
return [{ value: record.valueName, controllingFieldValues }];
|
|
66
|
+
})
|
|
67
|
+
.sort((a, b) => a.value.localeCompare(b.value));
|
|
68
|
+
if (canonical.length > 0)
|
|
69
|
+
out.valueSettings = canonical;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* EXT-2040 (the Picklist wire shape): a Picklist default is NOT a top-level CustomField
|
|
75
|
+
* `<defaultValue>` on the wire — the Metadata API carries it as the per-value `<default>` flag
|
|
76
|
+
* inside `valueSet.valueSetDefinition.value[]` (exactly where the sf-cli-wrapper deploys it).
|
|
77
|
+
* Return the default-flagged value's API name (fullName) so the snapshot can project it to
|
|
78
|
+
* `$.metadata.defaultValue`; undefined when no value is flagged (a global value set, or simply no
|
|
79
|
+
* default — never fabricated). Handles the single-`<value>` object collapse and the parser handing
|
|
80
|
+
* the flag back as the string 'true' or a real boolean.
|
|
81
|
+
*/
|
|
82
|
+
export function valueSetDefaultValue(raw) {
|
|
83
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
84
|
+
return undefined;
|
|
85
|
+
const def = raw.valueSetDefinition;
|
|
86
|
+
const rawValues = def?.value;
|
|
87
|
+
const valueList = rawValues === undefined ? [] : Array.isArray(rawValues) ? rawValues : [rawValues];
|
|
88
|
+
for (const v of valueList) {
|
|
89
|
+
if (!v || typeof v !== "object")
|
|
90
|
+
continue;
|
|
91
|
+
const entry = v;
|
|
92
|
+
if ((entry.default === true || entry.default === "true") && typeof entry.fullName === "string")
|
|
93
|
+
return entry.fullName;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Named numeric CustomField facets. XML leaves parse as STRINGS ("0"); the dedicated customField
|
|
99
|
+
* snapshot coerced these through a private asNumber of its own, but the generic-metadata path
|
|
100
|
+
* (formula / rollupSummary / picklistDependency / lookupRelationship / masterDetailRelationship)
|
|
101
|
+
* did not — so an equality check on `$.metadata.scale` compared 0 against "0" and false-failed
|
|
102
|
+
* (live-caught EXT-15347, a Number formula field with scale 0 scoring the family to 0 after a
|
|
103
|
+
* byte-perfect deploy). Both paths now share THIS, which is what keeps them from drifting apart
|
|
104
|
+
* again. Coerce at the read boundary, named keys only — never blanket-coerce (the
|
|
105
|
+
* customObjectSnapshot rule).
|
|
106
|
+
*
|
|
107
|
+
* This is EVERY member `@salesforce/types` declares as a number on CustomField (queried against
|
|
108
|
+
* src/direct/metadataSchema.generated.json), not the subset some family happened to hit. The old
|
|
109
|
+
* five-key list was that subset, and a snapshot is only as trustworthy as its narrowest coercion.
|
|
110
|
+
*/
|
|
111
|
+
const NUMERIC_FACET_KEYS = [
|
|
112
|
+
"length",
|
|
113
|
+
"precision",
|
|
114
|
+
"relationshipOrder",
|
|
115
|
+
"scale",
|
|
116
|
+
"startingNumber",
|
|
117
|
+
"visibleLines"
|
|
118
|
+
];
|
|
119
|
+
/**
|
|
120
|
+
* Named boolean CustomField facets — same read-boundary rule as the numerics, same provenance
|
|
121
|
+
* (every member the schema artifact declares `boolean` on CustomField).
|
|
122
|
+
*
|
|
123
|
+
* `required` was the only one of these ever coerced, because it was the only one the dedicated
|
|
124
|
+
* snapshot bothered to project. Widening the snapshot to carry every facet the org returns makes
|
|
125
|
+
* the rest reachable, and an uncoerced `unique: "true"` would land in the comparison substrate as a
|
|
126
|
+
* STRING — the exact class of defect EXT-966 and EXT-15347 exist for.
|
|
127
|
+
*
|
|
128
|
+
* `defaultValue` is deliberately NOT here: it is an xsd:string carrying formula syntax, so a
|
|
129
|
+
* checkbox default is legitimately the STRING "false" (see asDefaultValue in customFieldSnapshot).
|
|
130
|
+
* Coercing it would undo EXT-2040 rather than help it.
|
|
131
|
+
*/
|
|
132
|
+
const BOOLEAN_FACET_KEYS = [
|
|
133
|
+
"caseSensitive",
|
|
134
|
+
"deprecated",
|
|
135
|
+
"escapeMarkup",
|
|
136
|
+
"externalId",
|
|
137
|
+
"isAIPredictionField",
|
|
138
|
+
"isConvertLeadDisabled",
|
|
139
|
+
"isFilteringDisabled",
|
|
140
|
+
"isNameField",
|
|
141
|
+
"isSortingDisabled",
|
|
142
|
+
"populateExistingRows",
|
|
143
|
+
"reparentableMasterDetail",
|
|
144
|
+
"required",
|
|
145
|
+
"restrictedAdminField",
|
|
146
|
+
"stripMarkup",
|
|
147
|
+
"trackFeedHistory",
|
|
148
|
+
"trackHistory",
|
|
149
|
+
"trackTrending",
|
|
150
|
+
"translateData",
|
|
151
|
+
"unique",
|
|
152
|
+
"writeRequiresMasterRead"
|
|
153
|
+
];
|
|
154
|
+
/** Apply CustomField-level normalization (valueSet flattening + typed facet coercion) to a raw metadata object. */
|
|
155
|
+
export function normalizeCustomFieldMetadata(md) {
|
|
156
|
+
if (!md || typeof md !== "object")
|
|
157
|
+
return md ?? null;
|
|
158
|
+
const out = { ...md };
|
|
159
|
+
if (md.valueSet !== undefined)
|
|
160
|
+
out.valueSet = normalizeValueSet(md.valueSet);
|
|
161
|
+
for (const key of NUMERIC_FACET_KEYS) {
|
|
162
|
+
const v = out[key];
|
|
163
|
+
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)))
|
|
164
|
+
out[key] = Number(v);
|
|
165
|
+
}
|
|
166
|
+
for (const key of BOOLEAN_FACET_KEYS) {
|
|
167
|
+
if (out[key] === "true")
|
|
168
|
+
out[key] = true;
|
|
169
|
+
else if (out[key] === "false")
|
|
170
|
+
out[key] = false;
|
|
171
|
+
}
|
|
172
|
+
// LongTextArea omits <required> from the wire entirely — the platform forbids required long text
|
|
173
|
+
// areas — so the guaranteed value is projected instead of undefined (live-caught EXT-15345: the
|
|
174
|
+
// customField family false-failed `required == false` against an absent leaf).
|
|
175
|
+
if (out.required === undefined && out.type === "LongTextArea")
|
|
176
|
+
out.required = false;
|
|
177
|
+
return out;
|
|
178
|
+
}
|