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,119 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parsePlanFile } from "./planFile.js";
|
|
4
|
+
// The mechanical half of the fragment merge — verification, never authoring.
|
|
5
|
+
//
|
|
6
|
+
// When one oversized requirement is split across N analyzers, each writes
|
|
7
|
+
// `requirements/<extId>/plan.fragment-<n>.json` and THE SUPERVISOR merges them into the one
|
|
8
|
+
// canonical `plan.json` (playbook step 3). That merge is model-performed, and every reader
|
|
9
|
+
// downstream opens literally `plan.json` — so the two failure shapes are silent by construction:
|
|
10
|
+
// a merge that never happened makes the whole requirement VANISH from the wave order (a missing
|
|
11
|
+
// plan.json is otherwise just "the analyzer has not run yet"), and a merge that dropped a
|
|
12
|
+
// component ships a plan that looks complete. This audit closes both mechanically, from the
|
|
13
|
+
// fragments themselves: fragments with no readable canonical plan are refused as unmerged, and a
|
|
14
|
+
// canonical plan that omits a fragment's component — or carries a fullName two different
|
|
15
|
+
// fragments both claim, the playbook's named planning error — is refused with the component named.
|
|
16
|
+
//
|
|
17
|
+
// The audit VERIFIES the merge rather than performing it, on purpose: concatenation and unioning
|
|
18
|
+
// are mechanical, but the one-paragraph summary and any blocker-dedup judgment are the
|
|
19
|
+
// supervisor's, and an implicit merge inside a derivation would hide an authoring step ("the
|
|
20
|
+
// planner names the split; it never writes it"). plan_waves is the gate because it is the first
|
|
21
|
+
// reader in the loop — Configuration Item ownership and writeback read the same plan.json later,
|
|
22
|
+
// behind the same gate.
|
|
23
|
+
//
|
|
24
|
+
// Archived fragments are audited too: the playbook pins the archive at
|
|
25
|
+
// `<sessionDir>/analysis/fragments/<extId>/`, which keeps the merge verifiable on every later
|
|
26
|
+
// plan_waves call — a plan.json edit that drops a fragment component mid-batch is caught the same
|
|
27
|
+
// way the original merge is.
|
|
28
|
+
const FRAGMENT_NAME = /^plan\.fragment-\d+\.json$/;
|
|
29
|
+
async function listFragments(dir) {
|
|
30
|
+
const names = await fs.readdir(dir).catch(() => []);
|
|
31
|
+
return names
|
|
32
|
+
.filter((name) => FRAGMENT_NAME.test(name))
|
|
33
|
+
.sort()
|
|
34
|
+
.map((name) => path.join(dir, name));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Audit every requirement's plan fragments against its canonical plan.json.
|
|
38
|
+
*
|
|
39
|
+
* Requirements with no fragments anywhere are untouched by this — the ordinary "no plan.json yet"
|
|
40
|
+
* reporting in plan_waves keeps covering them, because absence without fragments still means "the
|
|
41
|
+
* analyzer has not run", which is a state the supervisor knowingly waits out.
|
|
42
|
+
*/
|
|
43
|
+
export async function auditPlanFragments(root, extIds) {
|
|
44
|
+
const audit = { unmerged: [], unreadable: [], dropped: [], duplicated: [], clean: true };
|
|
45
|
+
for (const extId of extIds) {
|
|
46
|
+
const requirementDir = path.join(root, "requirements", extId);
|
|
47
|
+
const fragments = [
|
|
48
|
+
...(await listFragments(requirementDir)),
|
|
49
|
+
...(await listFragments(path.join(root, "analysis", "fragments", extId)))
|
|
50
|
+
];
|
|
51
|
+
if (fragments.length === 0)
|
|
52
|
+
continue;
|
|
53
|
+
const canonicalText = await fs.readFile(path.join(requirementDir, "plan.json"), "utf8").catch(() => null);
|
|
54
|
+
const canonical = canonicalText === null ? null : parsePlanFile(canonicalText, extId);
|
|
55
|
+
if (canonical === null || !canonical.ok) {
|
|
56
|
+
// Missing and malformed refuse the same way: either way the canonical plan the fragments
|
|
57
|
+
// were supposed to become is not there to read, and ordering the batch around it would
|
|
58
|
+
// silently shrink it by one whole requirement.
|
|
59
|
+
audit.unmerged.push({ extId, fragments: fragments.map((f) => path.basename(f)) });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const canonicalKeys = new Set(canonical.plan.entries.map((entry) => entry.key));
|
|
63
|
+
const owners = new Map();
|
|
64
|
+
const droppedSeen = new Set();
|
|
65
|
+
for (const file of fragments) {
|
|
66
|
+
const fragment = path.basename(file);
|
|
67
|
+
const text = await fs.readFile(file, "utf8").catch(() => null);
|
|
68
|
+
const parsed = text === null ? null : parsePlanFile(text, extId, file);
|
|
69
|
+
if (parsed === null || !parsed.ok) {
|
|
70
|
+
audit.unreadable.push({
|
|
71
|
+
extId,
|
|
72
|
+
fragment,
|
|
73
|
+
detail: parsed === null ? "unreadable" : `${parsed.error.code} — ${parsed.error.message}`
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of parsed.plan.entries) {
|
|
78
|
+
// Two entries for one key WITHIN a fragment is an ordinary revision chain; the same key
|
|
79
|
+
// across two DIFFERENT fragments is the ownership conflict. A fragment sitting in both the
|
|
80
|
+
// requirement dir and the archive is one fragment, which the basename set already says.
|
|
81
|
+
let holders = owners.get(entry.key);
|
|
82
|
+
if (holders === undefined) {
|
|
83
|
+
holders = new Set();
|
|
84
|
+
owners.set(entry.key, holders);
|
|
85
|
+
}
|
|
86
|
+
holders.add(fragment);
|
|
87
|
+
const droppedKey = `${fragment} ${entry.key}`;
|
|
88
|
+
if (!canonicalKeys.has(entry.key) && !droppedSeen.has(droppedKey)) {
|
|
89
|
+
droppedSeen.add(droppedKey);
|
|
90
|
+
audit.dropped.push({ extId, fragment, key: entry.key });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const [key, holders] of owners) {
|
|
95
|
+
if (holders.size > 1)
|
|
96
|
+
audit.duplicated.push({ extId, key, fragments: [...holders].sort() });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
audit.clean =
|
|
100
|
+
audit.unmerged.length + audit.unreadable.length + audit.dropped.length + audit.duplicated.length === 0;
|
|
101
|
+
return audit;
|
|
102
|
+
}
|
|
103
|
+
/** The refusal text — each finding carries its own remediation, per the late-facet lesson. */
|
|
104
|
+
export function renderFragmentAudit(audit) {
|
|
105
|
+
const lines = ["Plan fragments in this session are not resolved — nothing is ordered until they are."];
|
|
106
|
+
for (const u of audit.unmerged) {
|
|
107
|
+
lines.push("", `${u.extId}: ${u.fragments.length} fragment(s) (${u.fragments.join(", ")}) and no readable canonical plan.json — the supervisor merge has not happened.`, ` Merge them yourself (components concatenated, assumptions/blockers unioned with fragment attribution, ONE supervisor-written summary), write requirements/${u.extId}/plan.json, archive the fragments to analysis/fragments/${u.extId}/, then re-run plan_waves.`);
|
|
108
|
+
}
|
|
109
|
+
for (const u of audit.unreadable) {
|
|
110
|
+
lines.push("", `${u.extId}: ${u.fragment} cannot be parsed (${u.detail}) — the merge cannot be verified against it.`, " Fix the fragment, or remove it if it was superseded by a corrected one.");
|
|
111
|
+
}
|
|
112
|
+
for (const d of audit.dropped) {
|
|
113
|
+
lines.push("", `${d.extId}: ${d.key} is owned by ${d.fragment} but absent from the merged plan.json — a component was dropped in the merge.`, " Restore it to plan.json, or correct the fragment so the two agree; a merge never silently omits.");
|
|
114
|
+
}
|
|
115
|
+
for (const d of audit.duplicated) {
|
|
116
|
+
lines.push("", `${d.extId}: ${d.key} is claimed by ${d.fragments.length} fragments (${d.fragments.join(", ")}) — a planning error, never merged through.`, " Resolve ownership with the fragments' owners and re-issue the corrected fragment(s).");
|
|
117
|
+
}
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { log } from "./result.js";
|
|
4
|
+
import { sessionDir } from "./workspace.js";
|
|
5
|
+
const PATTERN_STRENGTH = { enumeration: 0, targeted: 1, body: 2 };
|
|
6
|
+
/** The stronger of two patterns — R13's per-key fold. */
|
|
7
|
+
export function strongerPattern(a, b) {
|
|
8
|
+
return PATTERN_STRENGTH[a] >= PATTERN_STRENGTH[b] ? a : b;
|
|
9
|
+
}
|
|
10
|
+
export function readsLogPath(sessionId) {
|
|
11
|
+
return path.join(sessionDir(sessionId), "reads.jsonl");
|
|
12
|
+
}
|
|
13
|
+
let warned = false;
|
|
14
|
+
/**
|
|
15
|
+
* Append one record. Never throws.
|
|
16
|
+
*
|
|
17
|
+
* A missing session directory, an unwritable disk or an id that fails the workspace guard all cost
|
|
18
|
+
* a single stderr line and nothing else — the read the caller asked for still returns.
|
|
19
|
+
*/
|
|
20
|
+
export async function appendReadRecord(sessionId, input) {
|
|
21
|
+
try {
|
|
22
|
+
const record = { ...input, at: input.at ?? new Date().toISOString() };
|
|
23
|
+
const file = readsLogPath(sessionId);
|
|
24
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
25
|
+
// One line, one append. O_APPEND keeps concurrent writers from interleaving offsets, and the
|
|
26
|
+
// reader below tolerates a torn tail rather than failing the whole log for one bad line.
|
|
27
|
+
await fs.appendFile(file, `${JSON.stringify(record)}\n`, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (!warned) {
|
|
31
|
+
warned = true;
|
|
32
|
+
log("read provenance not being recorded:", error instanceof Error ? error.message : String(error));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Read the log back. A missing file is an empty log; an unparseable line is skipped, not fatal. */
|
|
37
|
+
export async function readReadsLog(sessionId) {
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = await fs.readFile(readsLogPath(sessionId), "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const line of raw.split("\n")) {
|
|
47
|
+
if (!line.trim())
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
out.push(JSON.parse(line));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A torn final line from a killed process. Everything before it is still evidence.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
const noOpaque = () => ({ enumeration: 0, targeted: 0, body: 0 });
|
|
59
|
+
/**
|
|
60
|
+
* The session's per-org read-set: union of keys, strongest pattern per key (R13), in ONE pass.
|
|
61
|
+
*
|
|
62
|
+
* Grouped by org id when there is one, by alias otherwise. Two aliases for the same recorded id
|
|
63
|
+
* collapse into one set; two records with no id and different aliases stay apart, because nothing
|
|
64
|
+
* on hand proves they are the same org.
|
|
65
|
+
*/
|
|
66
|
+
export function deriveReadSet(records) {
|
|
67
|
+
const groups = new Map();
|
|
68
|
+
for (const record of records) {
|
|
69
|
+
const groupKey = record.orgId ?? `alias:${record.orgAlias}`;
|
|
70
|
+
let group = groups.get(groupKey);
|
|
71
|
+
if (!group) {
|
|
72
|
+
group = {
|
|
73
|
+
set: {
|
|
74
|
+
orgId: record.orgId,
|
|
75
|
+
orgAliases: [],
|
|
76
|
+
components: [],
|
|
77
|
+
manifests: [],
|
|
78
|
+
opaqueReads: 0,
|
|
79
|
+
opaqueByPattern: noOpaque(),
|
|
80
|
+
calls: 0
|
|
81
|
+
},
|
|
82
|
+
byKey: new Map()
|
|
83
|
+
};
|
|
84
|
+
groups.set(groupKey, group);
|
|
85
|
+
}
|
|
86
|
+
const { set, byKey } = group;
|
|
87
|
+
set.calls += 1;
|
|
88
|
+
if (record.orgAlias && !set.orgAliases.includes(record.orgAlias))
|
|
89
|
+
set.orgAliases.push(record.orgAlias);
|
|
90
|
+
if (record.opaque) {
|
|
91
|
+
set.opaqueReads += 1;
|
|
92
|
+
set.opaqueByPattern[record.pattern] += 1;
|
|
93
|
+
}
|
|
94
|
+
for (const p of record.manifests ?? []) {
|
|
95
|
+
set.manifests.push({ path: p, tool: record.tool, pattern: record.pattern });
|
|
96
|
+
}
|
|
97
|
+
for (const key of record.keys) {
|
|
98
|
+
const existing = byKey.get(key);
|
|
99
|
+
if (!existing) {
|
|
100
|
+
byKey.set(key, { key, pattern: record.pattern, tools: [record.tool] });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
existing.pattern = strongerPattern(existing.pattern, record.pattern);
|
|
104
|
+
if (!existing.tools.includes(record.tool))
|
|
105
|
+
existing.tools.push(record.tool);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [...groups.values()].map(({ set, byKey }) => ({
|
|
109
|
+
...set,
|
|
110
|
+
components: [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key))
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
// ---- sf_read identity derivation ---------------------------------------------------------------
|
|
114
|
+
//
|
|
115
|
+
// `sf_read` is an open-world CLI passthrough, so unlike the other probes it cannot know in advance
|
|
116
|
+
// what it is about to look at. Component keys derive ONLY from metadata-shaped reads whose subject
|
|
117
|
+
// maps to a metadata type; every other read appends an opaque record (plan KTD, origin R11).
|
|
118
|
+
//
|
|
119
|
+
// The change-scan table in direct/applyLoop.ts is the precedent, not the source. It answers a
|
|
120
|
+
// different question — "which types carry a usable LastModifiedDate so we can ask what MOVED" — and
|
|
121
|
+
// is therefore restricted to types with that column and enriched with scan-only filters. This table
|
|
122
|
+
// answers "what identity did the rows this read returned name", so it carries the columns that
|
|
123
|
+
// reconstruct a metadata fullName and includes read-only views (FieldDefinition, EntityDefinition)
|
|
124
|
+
// that the change scan has no use for.
|
|
125
|
+
/**
|
|
126
|
+
* Columns whose value IS the metadata fullName, in preference order — TERMINAL: never joined to a
|
|
127
|
+
* parent, never suffixed.
|
|
128
|
+
*
|
|
129
|
+
* These are the qualified sources, and being qualified is declared here rather than inferred from
|
|
130
|
+
* the value. They belong to no single shape — `FullName` is a real column on every metadata-bearing
|
|
131
|
+
* Tooling object and `fullName` is the key inside a `Metadata` payload — so they are declared once
|
|
132
|
+
* for all shapes, while a shape's `nameFields` are leaves by construction.
|
|
133
|
+
*/
|
|
134
|
+
const QUALIFIED_NAME_COLUMNS = ["FullName", "Metadata.fullName"];
|
|
135
|
+
const ENTITY = "EntityDefinition.QualifiedApiName";
|
|
136
|
+
const METADATA_QUERY_SHAPES = [
|
|
137
|
+
{ object: "CustomField", metadataType: "CustomField", nameFields: ["QualifiedApiName", "DeveloperName"], parentField: ENTITY, suffix: "__c" },
|
|
138
|
+
// The read-only view describe_object queries. QualifiedApiName is exact, so no suffix guess.
|
|
139
|
+
{ object: "FieldDefinition", metadataType: "CustomField", nameFields: ["QualifiedApiName"], parentField: ENTITY },
|
|
140
|
+
{ object: "ValidationRule", metadataType: "ValidationRule", nameFields: ["ValidationName"], parentField: ENTITY },
|
|
141
|
+
// A Layout's metadata fullName joins object and label with a DASH, not a dot — the exact shape
|
|
142
|
+
// that made the change scan report a wave's own layouts as someone else's work.
|
|
143
|
+
{ object: "Layout", metadataType: "Layout", nameFields: ["Name"], parentField: ENTITY, join: "-" },
|
|
144
|
+
{ object: "CustomObject", metadataType: "CustomObject", nameFields: ["QualifiedApiName", "DeveloperName"], suffix: "__c" },
|
|
145
|
+
{ object: "EntityDefinition", metadataType: "CustomObject", nameFields: ["QualifiedApiName"] },
|
|
146
|
+
{ object: "CompactLayout", metadataType: "CompactLayout", nameFields: ["DeveloperName"], parentField: ENTITY },
|
|
147
|
+
{ object: "RecordType", metadataType: "RecordType", nameFields: ["DeveloperName"], parentField: "SobjectType" },
|
|
148
|
+
{ object: "WebLink", metadataType: "WebLink", nameFields: ["Name"], parentField: ENTITY },
|
|
149
|
+
// Flow rows are VERSIONS with no DeveloperName at all; FlowDefinition carries the API name the
|
|
150
|
+
// metadata type is addressed by.
|
|
151
|
+
{ object: "FlowDefinition", metadataType: "Flow", nameFields: ["DeveloperName"] },
|
|
152
|
+
{ object: "CustomTab", metadataType: "CustomTab", nameFields: ["DeveloperName"] },
|
|
153
|
+
{ object: "QuickActionDefinition", metadataType: "QuickAction", nameFields: ["DeveloperName"] },
|
|
154
|
+
{ object: "PermissionSet", metadataType: "PermissionSet", nameFields: ["Name"] },
|
|
155
|
+
{ object: "PermissionSetGroup", metadataType: "PermissionSetGroup", nameFields: ["DeveloperName"] },
|
|
156
|
+
{ object: "Profile", metadataType: "Profile", nameFields: ["Name"] },
|
|
157
|
+
{ object: "FlexiPage", metadataType: "FlexiPage", nameFields: ["DeveloperName"] },
|
|
158
|
+
{ object: "CustomApplication", metadataType: "CustomApplication", nameFields: ["DeveloperName"] },
|
|
159
|
+
{ object: "ApexClass", metadataType: "ApexClass", nameFields: ["Name"] },
|
|
160
|
+
{ object: "ApexTrigger", metadataType: "ApexTrigger", nameFields: ["Name"] }
|
|
161
|
+
];
|
|
162
|
+
const SHAPES_BY_OBJECT = new Map(METADATA_QUERY_SHAPES.map((s) => [s.object.toLowerCase(), s]));
|
|
163
|
+
/** Tooling body columns. Selecting either means the call read the component's definition. */
|
|
164
|
+
const BODY_COLUMNS = /^(metadata|fullname)$/i;
|
|
165
|
+
const SUFFIXED = /__(c|mdt|e|b|x|kav|chn|history|share)$/i;
|
|
166
|
+
function withSuffix(value, suffix) {
|
|
167
|
+
if (!suffix || SUFFIXED.test(value))
|
|
168
|
+
return value;
|
|
169
|
+
return `${value}${suffix}`;
|
|
170
|
+
}
|
|
171
|
+
/** Read a possibly-dotted column. Salesforce returns `EntityDefinition.X` NESTED, not flattened. */
|
|
172
|
+
function pluck(row, column) {
|
|
173
|
+
let node = row;
|
|
174
|
+
for (const part of column.split(".")) {
|
|
175
|
+
if (!node || typeof node !== "object")
|
|
176
|
+
return undefined;
|
|
177
|
+
node = node[part];
|
|
178
|
+
}
|
|
179
|
+
return typeof node === "string" && node !== "" ? node : undefined;
|
|
180
|
+
}
|
|
181
|
+
function flagValue(args, ...flags) {
|
|
182
|
+
for (const flag of flags) {
|
|
183
|
+
const i = args.indexOf(flag);
|
|
184
|
+
if (i >= 0 && args[i + 1] !== undefined)
|
|
185
|
+
return args[i + 1];
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
/** Split a SOQL statement into the three parts identity derivation needs. Nothing else is kept. */
|
|
190
|
+
function parseSoql(soql) {
|
|
191
|
+
const flat = soql.replace(/\s+/g, " ").trim();
|
|
192
|
+
const m = /^select\s+(.+?)\s+from\s+([A-Za-z][A-Za-z0-9_]*)\b(.*)$/i.exec(flat);
|
|
193
|
+
if (!m)
|
|
194
|
+
return null;
|
|
195
|
+
// A subquery in the SELECT list would make `from` the INNER object. Bailing to opaque is the
|
|
196
|
+
// right direction: a wrong key is a false accusation, a missing one is a bounded blind spot.
|
|
197
|
+
if (/\(\s*select\s/i.test(m[1]))
|
|
198
|
+
return null;
|
|
199
|
+
const tail = m[3] ?? "";
|
|
200
|
+
const w = /\bwhere\b(.*)$/i.exec(tail);
|
|
201
|
+
return { select: m[1], from: m[2], ...(w ? { where: w[1] } : {}) };
|
|
202
|
+
}
|
|
203
|
+
function queryPattern(select, where, shape) {
|
|
204
|
+
const columns = select.split(",").map((c) => c.trim());
|
|
205
|
+
if (columns.some((c) => BODY_COLUMNS.test(c)))
|
|
206
|
+
return "body";
|
|
207
|
+
// TARGETED means the call named the COMPONENT. A WHERE on the parent object is object-scoped
|
|
208
|
+
// enumeration — `describe_object` is exactly that shape, and calling it targeting would grade
|
|
209
|
+
// every field inventory as if the run had asked for each field by name.
|
|
210
|
+
//
|
|
211
|
+
// The lookbehind is load-bearing, not defensive: `EntityDefinition.QualifiedApiName = 'Account'`
|
|
212
|
+
// ends in a name field, so a bare word match graded every object-scoped field enumeration as a
|
|
213
|
+
// per-field targeting — the exact over-reading this pattern exists to prevent.
|
|
214
|
+
if (where && shape.nameFields.some((f) => new RegExp(`(?<![\\w.])${f}\\b`, "i").test(where)))
|
|
215
|
+
return "targeted";
|
|
216
|
+
return "enumeration";
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The row's own fully-qualified metadata fullName, when the payload states it outright.
|
|
220
|
+
*
|
|
221
|
+
* WHY THIS EXISTS (live-caught, PV corpus Arm A, 2026-08-02). `keysFromRows` used to derive identity
|
|
222
|
+
* only by RECONSTRUCTING it from SELECTed identity columns — a name field plus a parent plus a
|
|
223
|
+
* suffix guess. But the strongest read shape selects none of them: `SELECT Metadata FROM Layout
|
|
224
|
+
* WHERE Id = '...'` is the natural way to read a definition, and it names the component nowhere in
|
|
225
|
+
* its SELECT list. So every one of Arm A's six body-graded `sf_read` calls landed opaque, and
|
|
226
|
+
* body-grade overlap was close to non-functional for `sf_read` — the one pattern that can carry a
|
|
227
|
+
* prior arm's design across arms was the one most likely to go unattributed.
|
|
228
|
+
*
|
|
229
|
+
* Tooling answers this from the payload itself. `FullName` is a real column on the metadata-bearing
|
|
230
|
+
* Tooling objects and its value IS the metadata fullName, already qualified; the `Metadata` payload
|
|
231
|
+
* carries a `fullName` of its own. Either is exact — no join, no suffix restoration, no guess — so
|
|
232
|
+
* both are terminal and neither is re-qualified with a parent.
|
|
233
|
+
*
|
|
234
|
+
* WHAT IS DELIBERATELY NOT HERE. `Id`. A Tooling Id is not a fullName, and resolving one costs a
|
|
235
|
+
* second query against the org; recording `Layout:00h5g...` would add a key that can never join the
|
|
236
|
+
* mirror, dressing an unattributed read as an attributed one. A missing key is a bounded blind spot
|
|
237
|
+
* that the opaque count reports; a wrong key is a false accusation. The asymmetry decides it.
|
|
238
|
+
*
|
|
239
|
+
* A payload that states no name still derives nothing and stays opaque. This widens derivation to
|
|
240
|
+
* where the org already answered the question — it does not loosen it into guessing.
|
|
241
|
+
*
|
|
242
|
+
* Salesforce is deterministic about case here: the column is `FullName`, the key inside the
|
|
243
|
+
* `Metadata` payload is `fullName`. `pluck` rejects non-strings and empty strings, so a Tooling
|
|
244
|
+
* `"fullName": null` — the common shape when the column was not also selected — falls through.
|
|
245
|
+
*/
|
|
246
|
+
function qualifiedNameFromPayload(row) {
|
|
247
|
+
for (const column of QUALIFIED_NAME_COLUMNS) {
|
|
248
|
+
const stated = pluck(row, column);
|
|
249
|
+
if (stated)
|
|
250
|
+
return stated;
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
function keysFromRows(rows, shape) {
|
|
255
|
+
const keys = new Set();
|
|
256
|
+
for (const row of rows) {
|
|
257
|
+
// Preferred over reconstruction whenever present, because it is exact where reconstruction
|
|
258
|
+
// restores a dropped suffix and joins a parent the row may not even carry.
|
|
259
|
+
const stated = qualifiedNameFromPayload(row);
|
|
260
|
+
if (stated) {
|
|
261
|
+
keys.add(`${shape.metadataType}:${stated}`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
let name;
|
|
265
|
+
let matchedField;
|
|
266
|
+
for (const field of shape.nameFields) {
|
|
267
|
+
const value = pluck(row, field);
|
|
268
|
+
if (value) {
|
|
269
|
+
name = value;
|
|
270
|
+
matchedField = field;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!name)
|
|
275
|
+
continue;
|
|
276
|
+
// QualifiedApiName is already the exact API name; only DeveloperName needs the suffix restored.
|
|
277
|
+
const leaf = matchedField === "QualifiedApiName" ? name : withSuffix(name, shape.suffix);
|
|
278
|
+
if (!shape.parentField) {
|
|
279
|
+
keys.add(`${shape.metadataType}:${leaf}`);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const parent = pluck(row, shape.parentField);
|
|
283
|
+
// An unqualified row cannot be named. Recording the bare leaf would let a foreign
|
|
284
|
+
// `Contact.Status__c` stand in for our `Account.Status__c`.
|
|
285
|
+
if (!parent)
|
|
286
|
+
continue;
|
|
287
|
+
// The join is UNCONDITIONAL, because every column in `nameFields` is a leaf. It used to be
|
|
288
|
+
// skipped when the leaf contained a dot or a dash — a string-sniffed guess at "is this already
|
|
289
|
+
// qualified?" — which silently dropped the object from any component whose own label carries a
|
|
290
|
+
// version number or a region dash: `PV Asset Layout v1.2` on `PV_Asset__c` keyed as
|
|
291
|
+
// `Layout:PV Asset Layout v1.2`, a key that can never join the mirror. Qualified values come
|
|
292
|
+
// from QUALIFIED_NAME_COLUMNS and have already returned above.
|
|
293
|
+
keys.add(`${shape.metadataType}:${parent}${shape.join ?? "."}${leaf}`);
|
|
294
|
+
}
|
|
295
|
+
return [...keys];
|
|
296
|
+
}
|
|
297
|
+
const EMPTY = (scope, object) => ({
|
|
298
|
+
...(object ? { object } : {}),
|
|
299
|
+
pattern: "enumeration",
|
|
300
|
+
keys: [],
|
|
301
|
+
opaque: true,
|
|
302
|
+
scope,
|
|
303
|
+
rows: 0
|
|
304
|
+
});
|
|
305
|
+
/**
|
|
306
|
+
* Classify one `sf_read` call into a provenance record.
|
|
307
|
+
*
|
|
308
|
+
* `result` is the parsed `--json` envelope. Three shapes derive identities — a metadata-shaped SOQL
|
|
309
|
+
* query, `org list metadata`, and `sobject describe`, all of which return component names as data.
|
|
310
|
+
* Everything else (record data, limits, org display, an unrecognized command) is opaque BY DESIGN:
|
|
311
|
+
* the read happened and is recorded as unaccounted-for rather than dropped.
|
|
312
|
+
*/
|
|
313
|
+
export function classifySfRead(args, result) {
|
|
314
|
+
const canonical = args.filter((a) => !a.startsWith("-"));
|
|
315
|
+
const verb = args.slice(0, 3).join(" ").toLowerCase();
|
|
316
|
+
if (args[0] === "data" && args[1] === "query") {
|
|
317
|
+
const soql = flagValue(args, "-q", "--query");
|
|
318
|
+
const records = recordsOf(result);
|
|
319
|
+
const parsed = soql ? parseSoql(soql) : null;
|
|
320
|
+
if (!parsed)
|
|
321
|
+
return EMPTY("data query");
|
|
322
|
+
const shape = SHAPES_BY_OBJECT.get(parsed.from.toLowerCase());
|
|
323
|
+
// The object is recorded either way — an opaque record naming `Account` is what tells a later
|
|
324
|
+
// report this run read record data, not that it read nothing.
|
|
325
|
+
if (!shape)
|
|
326
|
+
return { ...EMPTY(`data query ${parsed.from}`, parsed.from), rows: records.length };
|
|
327
|
+
const keys = keysFromRows(records, shape);
|
|
328
|
+
return {
|
|
329
|
+
object: parsed.from,
|
|
330
|
+
pattern: queryPattern(parsed.select, parsed.where, shape),
|
|
331
|
+
keys,
|
|
332
|
+
// Rows that named nothing derivable (a COUNT(), an aggregate, a bare Id list) leave the read
|
|
333
|
+
// unaccounted for even though its object mapped.
|
|
334
|
+
opaque: keys.length === 0,
|
|
335
|
+
scope: `data query ${parsed.from}`,
|
|
336
|
+
rows: records.length
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (args[0] === "org" && args[1] === "list" && args[2] === "metadata") {
|
|
340
|
+
const type = flagValue(args, "-m", "--metadata-type");
|
|
341
|
+
// The response literally carries {type, fullName} per row — the one read shape whose identities
|
|
342
|
+
// need no reconstruction at all.
|
|
343
|
+
const rows = Array.isArray(result?.result)
|
|
344
|
+
? (result.result)
|
|
345
|
+
: [];
|
|
346
|
+
const keys = [
|
|
347
|
+
...new Set(rows
|
|
348
|
+
.map((r) => {
|
|
349
|
+
const full = typeof r.fullName === "string" ? r.fullName : undefined;
|
|
350
|
+
const t = typeof r.type === "string" ? r.type : type;
|
|
351
|
+
return full && t ? `${t}:${full}` : undefined;
|
|
352
|
+
})
|
|
353
|
+
.filter((k) => Boolean(k)))
|
|
354
|
+
];
|
|
355
|
+
return {
|
|
356
|
+
...(type ? { object: type } : {}),
|
|
357
|
+
pattern: "enumeration",
|
|
358
|
+
keys,
|
|
359
|
+
opaque: keys.length === 0,
|
|
360
|
+
scope: `org list metadata${type ? ` ${type}` : ""}`,
|
|
361
|
+
rows: rows.length
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (args[0] === "sobject" && args[1] === "describe") {
|
|
365
|
+
const subject = flagValue(args, "-s", "--sobject");
|
|
366
|
+
const described = result?.result;
|
|
367
|
+
const objectName = typeof described?.name === "string" ? described.name : subject;
|
|
368
|
+
const fields = Array.isArray(described?.fields) ? described.fields : [];
|
|
369
|
+
if (!objectName)
|
|
370
|
+
return EMPTY("sobject describe");
|
|
371
|
+
// A describe is a full field ENUMERATION of one object — the same access as describe_object,
|
|
372
|
+
// through a different door. Leaving it opaque would let a run enumerate an entire corpus here
|
|
373
|
+
// and still report an empty read-set.
|
|
374
|
+
//
|
|
375
|
+
// STANDARD fields are keyed too, not just `__c` ones: a field is a `CustomField` component
|
|
376
|
+
// whether or not its API name carries the suffix, and a delivered picklist-value change on
|
|
377
|
+
// `Account.Industry` is exactly the kind of prior-arm work this join has to be able to see.
|
|
378
|
+
const keys = new Set([`CustomObject:${objectName}`]);
|
|
379
|
+
for (const f of fields) {
|
|
380
|
+
const name = typeof f.name === "string" ? f.name : undefined;
|
|
381
|
+
if (name)
|
|
382
|
+
keys.add(`CustomField:${objectName}.${name}`);
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
object: objectName,
|
|
386
|
+
pattern: "enumeration",
|
|
387
|
+
keys: [...keys],
|
|
388
|
+
opaque: false,
|
|
389
|
+
scope: `sobject describe ${objectName}`,
|
|
390
|
+
rows: fields.length
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return EMPTY(canonical.slice(0, 3).join(" ") || verb || "sf_read");
|
|
394
|
+
}
|
|
395
|
+
function recordsOf(result) {
|
|
396
|
+
const records = result?.result?.records;
|
|
397
|
+
return Array.isArray(records) ? records : [];
|
|
398
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Tool registration instrumentation.
|
|
2
|
+
//
|
|
3
|
+
// The survivor of core/hotSwap.ts (deleted 2026-08-21 with the bespoke updater — the tool surface
|
|
4
|
+
// is fixed for the life of the process now, like any other MCP server's). What stays is the part
|
|
5
|
+
// that was never about swapping: wrapping registerTool ONCE so every tool — present and future —
|
|
6
|
+
// is timed by telemetry without each module having to remember, and keeping the roster of
|
|
7
|
+
// registered names for the startup log.
|
|
8
|
+
const names = new Set();
|
|
9
|
+
let patched = false;
|
|
10
|
+
export function instrumentRegistration(server, telemetry) {
|
|
11
|
+
if (patched)
|
|
12
|
+
return;
|
|
13
|
+
patched = true;
|
|
14
|
+
const original = server.registerTool.bind(server);
|
|
15
|
+
server.registerTool = (...args) => {
|
|
16
|
+
const [name, config, handler] = args;
|
|
17
|
+
const registered = original(name, config, telemetry.instrument(name, handler));
|
|
18
|
+
names.add(name);
|
|
19
|
+
return registered;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function registeredToolNames() {
|
|
23
|
+
return [...names].sort();
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// stdout is the JSON-RPC channel; all logging goes to stderr.
|
|
2
|
+
export const log = (...args) => console.error("[cc-mcp]", ...args);
|
|
3
|
+
/**
|
|
4
|
+
* The text is MIRRORED into structuredContent, and that mirror is load-bearing, not belt-and-braces.
|
|
5
|
+
*
|
|
6
|
+
* LIVE-CAUGHT (QA round 4, 2026-08-20): Claude Code delivers an MCP result's structuredContent to
|
|
7
|
+
* the model and silently drops every text block beside it (v2.0.21+; upstream #55677/#15412/#9962,
|
|
8
|
+
* closed not-planned). A tester activated on a 5-day-stale build and the composed STOP block, the
|
|
9
|
+
* viewer URL, and the next-steps all vanished — he had to ask for the update by hand. fail() never
|
|
10
|
+
* suffered this because its message already lives inside structuredContent; org.ts's CF75 comment
|
|
11
|
+
* records the same mechanism found per-tool. So: on the client we actually ship to, prose a tool
|
|
12
|
+
* returns beside a structured payload REACHES THE MODEL ONLY THROUGH THIS FIELD.
|
|
13
|
+
*
|
|
14
|
+
* `mirrorText: false` is for the handful of results whose text provably duplicates the structured
|
|
15
|
+
* payload byte-for-byte (sf_read's parsed branch) — mirroring there doubles the largest payloads in
|
|
16
|
+
* the loop for zero information. Never opt out a result whose text carries an instruction.
|
|
17
|
+
*/
|
|
18
|
+
export function ok(text, structured, opts) {
|
|
19
|
+
const mirror = opts?.mirrorText !== false && structured !== undefined && structured.text === undefined;
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text }],
|
|
22
|
+
...(structured ? { structuredContent: { ...structured, ...(mirror ? { text } : {}) } } : {})
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function fail(code, message, structured) {
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: "text", text: `${code}: ${message}` }],
|
|
28
|
+
isError: true,
|
|
29
|
+
structuredContent: { error: { code, message }, ...(structured ?? {}) }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function internalError(error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return fail("INTERNAL_ERROR", message);
|
|
35
|
+
}
|
|
36
|
+
export function stdoutTail(stdout, lines = 30) {
|
|
37
|
+
return stdout.split("\n").slice(-lines).join("\n");
|
|
38
|
+
}
|