sf-intelligence 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +1335 -110
  2. package/package.json +1 -1
  3. package/server.json +2 -2
package/dist/index.js CHANGED
@@ -1978,7 +1978,7 @@ var init_resolve_index = __esm({
1978
1978
  });
1979
1979
 
1980
1980
  // ../graph/dist/src/resolve.js
1981
- var DEFAULT_LIMIT, MAX_LIMIT, MIN_BASE, MATCHED_FLOOR, NONE_THRESHOLD, EXACT_THRESHOLD, EXACT_COVERAGE, PREFIX_EXACT_RATIO, CONTENDER_RATIO, LENGTH_RATIO_FLOOR, SYNONYM_SCORE, POP_K, COVERAGE_EXP, STRONG_ANCHOR, INTERNAL_RESOLVE_TYPES, queryNamesComponentType, TYPE_WEIGHT, typeWeight, isPureShortSubstringOfCompound, scoreToken, rollupKind, buildEvidence, resolveComponents;
1981
+ var DEFAULT_LIMIT, MAX_LIMIT, MIN_BASE, MATCHED_FLOOR, NONE_THRESHOLD, EXACT_THRESHOLD, EXACT_COVERAGE, PREFIX_EXACT_RATIO, CONTENDER_RATIO, LENGTH_RATIO_FLOOR, SYNONYM_SCORE, POP_K, COVERAGE_EXP, STRONG_ANCHOR, INTERNAL_RESOLVE_TYPES, queryNamesComponentType, TYPE_HINT_SINGLE_NOUNS, TYPE_HINT_NOUN_PAIRS, stripTypeHintNouns, TYPE_WEIGHT, typeWeight, isPureShortSubstringOfCompound, scoreToken, rollupKind, buildEvidence, resolveComponents;
1982
1982
  var init_resolve = __esm({
1983
1983
  "../graph/dist/src/resolve.js"() {
1984
1984
  "use strict";
@@ -2012,8 +2012,69 @@ var init_resolve = __esm({
2012
2012
  return "ApexClass";
2013
2013
  if (/\btriggers?\b/.test(lower))
2014
2014
  return "ApexTrigger";
2015
+ if (/\bpermission\s+sets?\b/.test(lower))
2016
+ return "PermissionSet";
2017
+ if (/\brecord\s+types?\b/.test(lower))
2018
+ return "RecordType";
2019
+ if (/\bprofiles?\b/.test(lower))
2020
+ return "Profile";
2015
2021
  return null;
2016
2022
  };
2023
+ TYPE_HINT_SINGLE_NOUNS = /* @__PURE__ */ new Set([
2024
+ "trigger",
2025
+ "triggers",
2026
+ "profile",
2027
+ "profiles",
2028
+ "field",
2029
+ "fields",
2030
+ "object",
2031
+ "objects",
2032
+ "flow",
2033
+ "flows",
2034
+ "component",
2035
+ "components"
2036
+ ]);
2037
+ TYPE_HINT_NOUN_PAIRS = [
2038
+ ["permission", "set"],
2039
+ ["permission", "sets"],
2040
+ ["record", "type"],
2041
+ ["record", "types"]
2042
+ ];
2043
+ stripTypeHintNouns = (raw) => {
2044
+ const words = raw.trim().split(/\s+/);
2045
+ const norm = (w2) => w2.toLowerCase().replace(/[^a-z0-9]/g, "");
2046
+ let start = 0;
2047
+ let end = words.length;
2048
+ const isPairAt = (i2) => i2 >= 0 && i2 + 1 < end && TYPE_HINT_NOUN_PAIRS.some(([a2, b2]) => norm(words[i2]) === a2 && norm(words[i2 + 1]) === b2);
2049
+ for (; ; ) {
2050
+ if (start < end && /^(?:the|a|an)$/i.test(norm(words[start]))) {
2051
+ start += 1;
2052
+ continue;
2053
+ }
2054
+ if (isPairAt(start) && start + 2 < end) {
2055
+ start += 2;
2056
+ continue;
2057
+ }
2058
+ if (start < end - 1 && TYPE_HINT_SINGLE_NOUNS.has(norm(words[start]))) {
2059
+ start += 1;
2060
+ continue;
2061
+ }
2062
+ break;
2063
+ }
2064
+ for (; ; ) {
2065
+ if (isPairAt(end - 2) && end - 2 > start) {
2066
+ end -= 2;
2067
+ continue;
2068
+ }
2069
+ if (end - 1 > start && TYPE_HINT_SINGLE_NOUNS.has(norm(words[end - 1]))) {
2070
+ end -= 1;
2071
+ continue;
2072
+ }
2073
+ break;
2074
+ }
2075
+ const kept = words.slice(start, end).join(" ");
2076
+ return kept.length > 0 ? kept : raw;
2077
+ };
2017
2078
  TYPE_WEIGHT = {
2018
2079
  CustomObject: 1,
2019
2080
  CustomField: 0.95,
@@ -2083,8 +2144,21 @@ var init_resolve = __esm({
2083
2144
  resolveComponents = async (store, query, options) => {
2084
2145
  const limit = Math.min(options?.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
2085
2146
  const minBase = options?.minScore ?? MIN_BASE;
2086
- const queryTokens = tokenizeText(query, { expandPhrases: true });
2147
+ const strippedQuery = stripTypeHintNouns(query);
2148
+ const queryTokens = tokenizeText(strippedQuery, { expandPhrases: true });
2087
2149
  const normQuery = normalizeName(query);
2150
+ const normStrippedQuery = strippedQuery.length < query.trim().length ? normalizeName(strippedQuery) : normQuery;
2151
+ const queryTrimmed = query.trim();
2152
+ const normAfterTypePrefix = (() => {
2153
+ if (!queryTrimmed.includes(":") || /\s/.test(queryTrimmed))
2154
+ return normQuery;
2155
+ const colonIdx = queryTrimmed.indexOf(":");
2156
+ const afterColon = queryTrimmed.slice(colonIdx + 1);
2157
+ if (afterColon.includes(":") || afterColon.includes("."))
2158
+ return normQuery;
2159
+ const n2 = normalizeName(afterColon);
2160
+ return n2.length >= 2 ? n2 : normQuery;
2161
+ })();
2088
2162
  const querySpaceWords = new Set(query.trim().split(/\s+/).map(normalizeName).filter((w2) => w2.length > 0));
2089
2163
  let dottedObjectNorm = null;
2090
2164
  let dottedFieldNorm = null;
@@ -2110,7 +2184,20 @@ var init_resolve = __esm({
2110
2184
  message: `resolveComponents: ${e2.message}`
2111
2185
  });
2112
2186
  }
2113
- const candidateIdx = gatherCandidates(index, queryTokens, normQuery);
2187
+ const candidateIdxSet = new Set(gatherCandidates(index, queryTokens, normQuery));
2188
+ if (normStrippedQuery !== normQuery) {
2189
+ const extra = index.byNormName.get(normStrippedQuery);
2190
+ if (extra !== void 0)
2191
+ for (const i2 of extra)
2192
+ candidateIdxSet.add(i2);
2193
+ }
2194
+ if (normAfterTypePrefix !== normQuery && normAfterTypePrefix !== normStrippedQuery) {
2195
+ const extra = index.byNormName.get(normAfterTypePrefix);
2196
+ if (extra !== void 0)
2197
+ for (const i2 of extra)
2198
+ candidateIdxSet.add(i2);
2199
+ }
2200
+ const candidateIdx = [...candidateIdxSet];
2114
2201
  const objectNormNames = /* @__PURE__ */ new Set();
2115
2202
  for (const n2 of index.nodes) {
2116
2203
  if (n2.type === "CustomObject")
@@ -2161,7 +2248,8 @@ var init_resolve = __esm({
2161
2248
  }
2162
2249
  const crossObjectFieldDecoy = node.type === "CustomField" && namedObjectWords.size > 0 && (node.parentApiName === null || !namedObjectWords.has(normalizeName(node.parentApiName)));
2163
2250
  const dottedExact = dottedObjectNorm !== null && dottedFieldNorm !== null && node.parentApiName !== null && normalizeName(node.parentApiName) === dottedObjectNorm && normalizeName(node.apiName) === dottedFieldNorm;
2164
- const wholeExact = dottedExact || normQuery.length >= 2 && node.normName === normQuery && query.includes(".") === node.apiName.includes(".") && !crossObjectFieldDecoy;
2251
+ const wholeExactNormMatch = normQuery.length >= 2 && node.normName === normQuery || normStrippedQuery !== normQuery && normStrippedQuery.length >= 2 && node.normName === normStrippedQuery || normAfterTypePrefix !== normQuery && normAfterTypePrefix.length >= 2 && node.normName === normAfterTypePrefix;
2252
+ const wholeExact = dottedExact || wholeExactNormMatch && query.includes(".") === node.apiName.includes(".") && !crossObjectFieldDecoy;
2165
2253
  pass1.push({ node, perToken, wholeExact, parentMatched });
2166
2254
  }
2167
2255
  const anchorIdx = [];
@@ -2255,12 +2343,6 @@ var init_resolve = __esm({
2255
2343
  return 1;
2256
2344
  };
2257
2345
  scored.sort((a2, b2) => {
2258
- if (namedTypeForSort !== null) {
2259
- const ttA = typeIntentTier(a2.type);
2260
- const ttB = typeIntentTier(b2.type);
2261
- if (ttA !== ttB)
2262
- return ttA - ttB;
2263
- }
2264
2346
  const weA = wholeExactIds.has(a2.id) ? 0 : 1;
2265
2347
  const weB = wholeExactIds.has(b2.id) ? 0 : 1;
2266
2348
  if (weA !== weB)
@@ -2269,6 +2351,12 @@ var init_resolve = __esm({
2269
2351
  const tierB = b2.base >= EXACT_THRESHOLD ? 0 : 1;
2270
2352
  if (tierA !== tierB)
2271
2353
  return tierA - tierB;
2354
+ if (namedTypeForSort !== null) {
2355
+ const ttA = typeIntentTier(a2.type);
2356
+ const ttB = typeIntentTier(b2.type);
2357
+ if (ttA !== ttB)
2358
+ return ttA - ttB;
2359
+ }
2272
2360
  const pmA = parentMatchedIds.has(a2.id) ? 0 : 1;
2273
2361
  const pmB = parentMatchedIds.has(b2.id) ? 0 : 1;
2274
2362
  if (pmA !== pmB)
@@ -24094,7 +24182,7 @@ ${renderTrustFooter(trust)}`;
24094
24182
  import { appendFile as appendFile3, mkdir as mkdir8 } from "node:fs/promises";
24095
24183
  import { homedir as homedir3 } from "node:os";
24096
24184
  import { dirname as dirname18, join as join13 } from "node:path";
24097
- var riskForIntent, routeFromRule, routeForSelectedIntent, normalize, deriveSaveEvent, deriveKnowledgeTopic, deriveListType, FIELD_PARENT_OBJECTS, deriveFieldListParent, deriveMetadataParentId, deriveObjectApiFromQuestion, deriveImpactHops, FIELD_MAP_OBJECT, deriveFieldMappingArgs, deriveLayoutForUserArgs, derivePiiInventoryArgs, deriveMetadataCountArgs, deriveOmniType, routeText, RULES, alternativeFromIntent, semanticAlternatives, intentLabel, classifyQuestion, gapLogPath, logGapIfAny;
24185
+ var riskForIntent, routeFromRule, routeForSelectedIntent, normalize, NAMED_COMPONENT_ID, NAMED_FIELD_ID, deriveSaveEvent, deriveKnowledgeTopic, deriveListType, FIELD_PARENT_OBJECTS, deriveFieldListParent, deriveMetadataParentId, NON_OBJECT_CAPTURES, deriveObjectApiFromQuestion, deriveImpactHops, FIELD_MAP_OBJECT, deriveFieldMappingArgs, deriveLayoutForUserArgs, derivePiiInventoryArgs, deriveMetadataCountArgs, deriveOmniType, routeText, RULES, alternativeFromIntent, semanticAlternatives, intentLabel, classifyQuestion, gapLogPath, logGapIfAny;
24098
24186
  var init_intent_router = __esm({
24099
24187
  "../mcp/dist/src/intent-router.js"() {
24100
24188
  "use strict";
@@ -24149,6 +24237,8 @@ var init_intent_router = __esm({
24149
24237
  };
24150
24238
  };
24151
24239
  normalize = (q2) => q2.trim().toLowerCase().replace(/[‘’ʼ]/g, "'").replace(/\s+/g, " ");
24240
+ NAMED_COMPONENT_ID = "(?!\\w*__)[a-z][a-z0-9]*_[a-z0-9]+_[a-z0-9_]*[a-z0-9]";
24241
+ NAMED_FIELD_ID = "(?:[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*|[a-z][a-z0-9_]*__c)";
24152
24242
  deriveSaveEvent = (q2) => {
24153
24243
  if (/\b(undelet|restor)/.test(q2))
24154
24244
  return "undelete";
@@ -24183,6 +24273,8 @@ var init_intent_router = __esm({
24183
24273
  return "apex-testing";
24184
24274
  if (/\bstandard\s+profiles?\b.*\b(ship|new\s+org)\b/.test(q2))
24185
24275
  return "profiles-vs-permission-sets";
24276
+ if (/\bwhat\s+is\s+an?\s+(?:profile|permission\s+set)\b/.test(q2))
24277
+ return "profiles-vs-permission-sets";
24186
24278
  if (/\b(person\s+accounts?)\b/.test(q2))
24187
24279
  return "standard-vs-custom-objects";
24188
24280
  if (/\b(ci\/cd|source\s+control|deployment\s+(and\s+)?release)\b/.test(q2))
@@ -24276,14 +24368,41 @@ var init_intent_router = __esm({
24276
24368
  return `CustomObject:${onObject[1]}`;
24277
24369
  return void 0;
24278
24370
  };
24371
+ NON_OBJECT_CAPTURES = /* @__PURE__ */ new Set([
24372
+ "save",
24373
+ "saves",
24374
+ "saving",
24375
+ "insert",
24376
+ "update",
24377
+ "delete",
24378
+ "undelete",
24379
+ "create",
24380
+ "creation",
24381
+ "edit",
24382
+ "record",
24383
+ "records",
24384
+ "object",
24385
+ "objects",
24386
+ "it",
24387
+ "this",
24388
+ "that",
24389
+ "them",
24390
+ "each",
24391
+ "every",
24392
+ "all"
24393
+ ]);
24279
24394
  deriveObjectApiFromQuestion = (q2, question) => {
24280
24395
  const source = question ?? q2;
24281
24396
  const toolObject = source.match(/\b(?:automation_build_advisor|order_of_execution|apex_build_advisor)\b\s+(?:on\s+)?([A-Za-z][A-Za-z0-9_]*(?:__c|__mdt|__e)?)\b/i);
24282
24397
  if (toolObject?.[1] !== void 0)
24283
24398
  return toolObject[1];
24284
- const onObject = source.match(/\b(?:on|for|to|access\s+to)\s+(?:the\s+|an\s+|a\s+)?([A-Za-z][A-Za-z0-9_]*(?:__c|__mdt|__e)?)\b/i);
24285
- if (onObject?.[1] !== void 0)
24286
- return onObject[1];
24399
+ const onObjectRe = /\b(?:on|for|to|access\s+to)\s+(?:the\s+|an\s+|a\s+)?([A-Za-z][A-Za-z0-9_]*(?:__c|__mdt|__e)?)\b/gi;
24400
+ for (const match of source.matchAll(onObjectRe)) {
24401
+ const capture = match[1];
24402
+ if (capture !== void 0 && !NON_OBJECT_CAPTURES.has(capture.toLowerCase())) {
24403
+ return capture;
24404
+ }
24405
+ }
24287
24406
  const dmlObject = source.match(/\b(?:update|insert|delete|save|create|edit)\s+(?:a\s+|an\s+|the\s+)?([A-Za-z][A-Za-z0-9_]*(?:__c|__mdt|__e)?)\b/);
24288
24407
  if (dmlObject?.[1] !== void 0)
24289
24408
  return dmlObject[1];
@@ -24621,7 +24740,13 @@ var init_intent_router = __esm({
24621
24740
  liveRequired: false,
24622
24741
  needsResolve: true,
24623
24742
  reason: "Explicit downstream_effects invocation.",
24624
- patterns: [/\bdownstream_effects\b/]
24743
+ patterns: [
24744
+ /\bdownstream_effects\b/,
24745
+ // "downstream effects of changing X" — the natural phrasing. Previously
24746
+ // caught only by pii-flow's bare \bdownstream\b catch-all (eval family E
24747
+ // demoted that), so the dedicated tool now owns its own noun phrase.
24748
+ /\b(?:downstream|ripple)\s+effects?\b/
24749
+ ]
24625
24750
  },
24626
24751
  {
24627
24752
  intent: "package-impact",
@@ -24679,7 +24804,17 @@ var init_intent_router = __esm({
24679
24804
  liveRequired: false,
24680
24805
  needsResolve: true,
24681
24806
  reason: "Explicit async_chain_depth invocation.",
24682
- patterns: [/\basync_chain_depth\b/]
24807
+ patterns: [
24808
+ /\basync_chain_depth\b/,
24809
+ // NL async-chain shapes (flow-family REACH). High precision: an explicit
24810
+ // async-chain-depth / async-Apex-limit concern (queueable→queueable /
24811
+ // future/batch chaining against the 5-deep / async-Apex governor limit).
24812
+ /\basync\s+chain\b/,
24813
+ /\basync[-\s]?apex[-\s]?limit\b/,
24814
+ /\basync[-\s]apex[-\s]limit\b/,
24815
+ /\bhow\s+deep\b[^.?!]{0,40}\basync\b/,
24816
+ /\b(queueable|future|batch)\b[^.?!]{0,40}\bchain\w*\b[^.?!]{0,40}\b(depth|deep|limit)\b/
24817
+ ]
24683
24818
  },
24684
24819
  {
24685
24820
  intent: "compliance",
@@ -24940,6 +25075,32 @@ var init_intent_router = __esm({
24940
25075
  /\bhow\s+many\s+users?\b.*\bon\b.*\bprofiles?\b/
24941
25076
  ]
24942
25077
  },
25078
+ {
25079
+ // "list everyone with the X profile" is a USER ROSTER ask: user-to-profile
25080
+ // assignment is runtime User-record state, not vault metadata — the schema
25081
+ // list rule used to claim it via "list ... profiles" and answer with the
25082
+ // Profile METADATA catalog (eval family D). live_group_count genuinely
25083
+ // answers the count side (Users grouped by ProfileId) and
25084
+ // live_inactive_users the login-activity side; the name-by-name roster
25085
+ // itself is disclosed as a partial answer, never papered over.
25086
+ intent: "profile-user-roster",
25087
+ plane: "live",
25088
+ tools: ["sfi.live_group_count", "sfi.live_inactive_users"],
25089
+ liveRequired: true,
25090
+ needsResolve: false,
25091
+ reason: "Which users hold a profile is runtime User-record state: count them live grouped by ProfileId (live_group_count); login-activity detail comes from live_inactive_users.",
25092
+ gap: {
25093
+ category: "profile-user-roster",
25094
+ note: "Partial answer: live_group_count returns user COUNTS per profile and live_inactive_users lists dormant users, but a full name-by-name user roster per profile is not a built capability yet."
25095
+ },
25096
+ suggestArgs: () => ({ objectApiName: "User", groupByField: "ProfileId" }),
25097
+ patterns: [
25098
+ /\b(list|show|who\s+are)\b[^.?!]{0,20}\b(everyone|everybody|all\s+(the\s+)?users?|the\s+users?|people)\b[^.?!]{0,40}\b(with|on|assigned|holding|having)\b[^.?!]{0,40}\bprofile\b/,
25099
+ /\b(which|what)\s+users?\b[^.?!]{0,40}\b(have|hold|are\s+on|with|assigned)\b[^.?!]{0,40}\bprofile\b/,
25100
+ /\bwho\s+(has|holds|is\s+assigned)\b[^.?!]{0,40}\bprofile\b/,
25101
+ /\beveryone\b[^.?!]{0,30}\b(with|on|assigned)\b[^.?!]{0,40}\bprofile\b/
25102
+ ]
25103
+ },
24943
25104
  {
24944
25105
  intent: "stale-metadata",
24945
25106
  plane: "vault",
@@ -24969,10 +25130,25 @@ var init_intent_router = __esm({
24969
25130
  needsResolve: false,
24970
25131
  reason: "License provisioning/usage and reclaimable seats are live org state (UserLicense / PermissionSetLicense + login activity).",
24971
25132
  patterns: [
24972
- /\b(licen[sc]e|seat)s?\b.*\b(usage|used|unused|utili[sz]ation|utilized|reclaim|reclaimable|available|free|provision|assigned|wasted|cost|optimi[sz])/,
24973
- /\b(usage|utili[sz]ation|utilized|reclaim|reclaimable|unused|provision|assigned|wasted)\b.*\b(licen[sc]e|seat)s?\b/,
25133
+ // Guarded (eval family C — qualifier hijack): "seats"/"license" as a
25134
+ // TRAILING modifier must not drag a permission-set ASSIGNMENT question
25135
+ // ("who is assigned the X permission set — are we wasting seats?") onto
25136
+ // the live license counter. When the question mentions a permission set
25137
+ // that is NOT the literal "permission set license" (PSL) noun, the
25138
+ // perm-set rules below own it; genuine PSL asks keep the third pattern.
25139
+ /^(?!.*\bpermission\s+sets?\b(?!\s+licen[sc]e)).*\b(licen[sc]e|seat)s?\b.*\b(usage|used|unused|utili[sz]ation|utilized|reclaim|reclaimable|available|free|provision|assigned|wasted|cost|optimi[sz])/,
25140
+ /^(?!.*\bpermission\s+sets?\b(?!\s+licen[sc]e)).*\b(usage|utili[sz]ation|utilized|reclaim|reclaimable|unused|provision|assigned|wasted)\b.*\b(licen[sc]e|seat)s?\b/,
24974
25141
  /\bpermission\s+set\s+licen[sc]e/,
24975
- /\bhow\s+many\s+(licen[sc]e|seat)s?\b/
25142
+ /\bhow\s+many\s+(licen[sc]e|seat)s?\b/,
25143
+ // DISCOVERY/META REACH: "how many <LicenseType> and <LicenseType>
25144
+ // licenses are we actually USING vs what we're PAYING for" — the
25145
+ // provisioning-vs-consumption ask. The base patterns above keyed on
25146
+ // `used`/`usage` (not the gerund "using") and on "how many licenses"
25147
+ // adjacent, so "how many Salesforce and Community licenses … using"
25148
+ // fell through. Anchor on "how many … licenses … using/paying" (the
25149
+ // provisioned-vs-paid frame), still guarded off a perm-set-assignment
25150
+ // question by the leading negative lookahead style used above.
25151
+ /^(?!.*\bpermission\s+sets?\b(?!\s+licen[sc]e)).*\bhow\s+many\b[^.?!]{0,60}\blicen[sc]es?\b[^.?!]{0,60}\b(?:using|use|paying|pay|provision\w*|actually\s+us\w*)\b/
24976
25152
  ]
24977
25153
  },
24978
25154
  {
@@ -24991,7 +25167,9 @@ var init_intent_router = __esm({
24991
25167
  // count (record-count) or a permission audit (over-permission), not dormancy.
24992
25168
  /\bhow\s+many\b.*\b(inactive|dormant|stale)\b.*\busers?\b/,
24993
25169
  /\bhow\s+many\b.*\busers?\b.*\b(inactive|dormant|stale|haven'?t\s+logged|not\s+logged)\b/,
24994
- /\b(license|seat)s?\b.*\b(reclaim|unused|free|available)\b/
25170
+ // Same permission-set guard as license-usage (eval family C): a seat
25171
+ // modifier on a perm-set-assignment ask must not land on login activity.
25172
+ /^(?!.*\bpermission\s+sets?\b(?!\s+licen[sc]e)).*\b(license|seat)s?\b.*\b(reclaim|unused|free|available)\b/
24995
25173
  ]
24996
25174
  },
24997
25175
  {
@@ -25175,7 +25353,14 @@ var init_intent_router = __esm({
25175
25353
  /\brecord\s+counts?\b.*\b(per|by|across)\b.*\bobjects?\b/,
25176
25354
  /\bstorage\b.*\b(by|per)\b.*\bobject\b/,
25177
25355
  /\bdata\s+volume\b.*\bobject\b/,
25178
- /\btop\b.*\bobjects?\b.*\b(by|with)\b.*\b(records?|rows?)\b/
25356
+ /\btop\b.*\bobjects?\b.*\b(by|with)\b.*\b(records?|rows?)\b/,
25357
+ // "which custom objects are essentially empty in prod" — whether an
25358
+ // object holds records is the SAME live per-object COUNT read from the
25359
+ // other end (zero/near-zero instead of most); it was unrouted and the
25360
+ // vault cannot answer it at all (eval family D).
25361
+ /\b(which|what)\b[^.?!]{0,40}\bobjects?\b[^.?!]{0,50}\bempty\b/,
25362
+ /\bempty\b[^.?!]{0,20}\b(custom\s+)?objects?\b/,
25363
+ /\bobjects?\b[^.?!]{0,40}\b(no|zero|barely\s+any|hardly\s+any)\s+(records?|rows?|data)\b/
25179
25364
  ]
25180
25365
  },
25181
25366
  {
@@ -25332,7 +25517,11 @@ var init_intent_router = __esm({
25332
25517
  reason: "Report inventory is in the vault; stale/unused needs live LastRunDate.",
25333
25518
  patterns: [
25334
25519
  /\breports?\b.*\b(useless|unused|stale|dead|old|never\s+run|not\s+used|broken)\b/,
25335
- /\b(reports?|dashboards?)\b.*\b(cover|covers|about|for)\b(?!.*\breport\s+types?\b)/,
25520
+ // Guarded (eval family C): "compliance report … who touches the X field"
25521
+ // uses "report" as the requested DELIVERABLE, not the subject — the
25522
+ // head question is field access, so a compliance/who-touches frame must
25523
+ // not land on live report run-history.
25524
+ /^(?!.*\b(?:compliance|who\s+touch)\w*\b).*\b(reports?|dashboards?)\b.*\b(cover|covers|about|for)\b(?!.*\breport\s+types?\b)/,
25336
25525
  /\b(useless|unused|stale|dead)\b.*\breports?\b/,
25337
25526
  /\b(dashboards?)\b.*\b(unused|stale|broken|refresh)\b/,
25338
25527
  /\breports?\b.*\b(not\s+run|haven'?t\s+been\s+run)\b/,
@@ -25402,7 +25591,14 @@ var init_intent_router = __esm({
25402
25591
  /\b(which|what)\s+profiles?\b.*\bcan\b.*\brun\b.*\breports?\b/,
25403
25592
  // API-enabled login profiles.
25404
25593
  /\b(which|what)\s+profiles?\b.*\b(api|log\s+in|login)\b/,
25405
- /\bprofiles?\b.*\b(log\s+in|login)\b.*\bapi\b/
25594
+ /\bprofiles?\b.*\b(log\s+in|login)\b.*\bapi\b/,
25595
+ // REACH (permissions/access cluster) — FORWARD run-access: "which screen
25596
+ // flows are exposed / available / visible / assigned to the <Named>
25597
+ // profile / perm set". This is the granter's OWN runnableFlows list
25598
+ // (user_ability), not the reverse who_can_run (which starts from a flow).
25599
+ // "exposed to <profile>" is the natural phrasing the existing
25600
+ // "what flows can X run" templates missed.
25601
+ /\bwhich\s+(?:screen\s+)?flows?\b[^.?!]{0,40}\b(?:exposed|available|visible|assigned)\s+to\b[^.?!]{0,30}\b(?:profile|perm\s*sets?|permission\s+sets?|user)\b/
25406
25602
  ]
25407
25603
  },
25408
25604
  {
@@ -25415,7 +25611,20 @@ var init_intent_router = __esm({
25415
25611
  patterns: [
25416
25612
  /\bwho\s+can\s+run\b.*\bflow/,
25417
25613
  /\b(which|what)\s+(profiles?|permission\s+sets?|permsets?)\b.*\brun\b.*\bflow/,
25418
- /\bwho\s+(can|is\s+able\s+to)\s+run\b.*\bflow/
25614
+ /\bwho\s+(can|is\s+able\s+to)\s+run\b.*\bflow/,
25615
+ // REACH (permissions/access cluster): the existing templates broke on
25616
+ // (1) an adverb between who/can and run ("who EXACTLY can run …",
25617
+ // "who ACTUALLY can run …") and (2) a Flow named by its API id with a
25618
+ // `_flow` / `_screen_flow` suffix but no separate bare word "flow"
25619
+ // ("run Some_Named_Screen_Flow"). `FLOWREF` matches the bare word
25620
+ // "flow(s)", the `_flow` suffix (no leading `\b`, so it fires inside a
25621
+ // multi-underscore API name), or "screen flow(s)". Two shapes:
25622
+ // (a) who [adverb] can run … <flow>
25623
+ // (b) can the <profile/perm set/user> run … <flow>
25624
+ // Both require the RUN verb + a flow reference, so they never steal a
25625
+ // record/object/field access ask (no "run … flow" there).
25626
+ /\bwho\b[^.?!]{0,20}\bcan\b[^.?!]{0,15}\brun\b[^.?!]{0,60}(?:\bflows?\b|_flow\b|screen\s+flows?\b)/,
25627
+ /\bcan\b[^.?!]{0,40}\b(?:profile|perm\s*sets?|permission\s+sets?|user)\b[^.?!]{0,20}\brun\b[^.?!]{0,60}(?:\bflows?\b|_flow\b|screen\s+flows?\b)/
25419
25628
  ]
25420
25629
  },
25421
25630
  {
@@ -25497,7 +25706,15 @@ var init_intent_router = __esm({
25497
25706
  // — the recordtype→profile grammar the forward templates above missed. Same
25498
25707
  // recordTypeVisibilities modeling, answered from the profile side.
25499
25708
  /\bwhich\s+record\s+types?\b.*\b(available|access|assigned)\s+to\b.*\bprofiles?\b/,
25500
- /\b(available|access|assigned)\s+to\b.*\bprofiles?\b.*\brecord\s+types?\b/
25709
+ /\b(available|access|assigned)\s+to\b.*\bprofiles?\b.*\brecord\s+types?\b/,
25710
+ // REACH (permissions/access cluster) — "why can't / won't <user> create /
25711
+ // pick / select the <X> RECORD TYPE" is a recordTypeVisibilities gap: the
25712
+ // container simply doesn't have that record type visible-for-create. The
25713
+ // literal "record type" / "recordtypeid" noun keeps this off why-cant-see
25714
+ // (record SHARING) and off object-access (object CRUD) — both of which
25715
+ // lack the record-type noun.
25716
+ /\bwhy\s+(?:can'?t|won'?t|cannot)\b[^.?!]{0,60}\b(?:create|pick|select|use|choose|see|open)\b[^.?!]{0,40}\brecord\s+type\b/,
25717
+ /\brecord\s?type\s?id\b[^.?!]{0,60}\b(?:pick|select|choose|create|use)\b/
25501
25718
  ]
25502
25719
  },
25503
25720
  {
@@ -25515,7 +25732,32 @@ var init_intent_router = __esm({
25515
25732
  /\bwhat\s+permissions?\s+(does|do)\b.*\b(profiles?|permission\s+sets?|users?)\b/,
25516
25733
  // Permission sets assigned to a named user (baseline-300 gap).
25517
25734
  /\bpermission\s+sets?\b.*\bassigned\b.*\buser\b/,
25518
- /\bwhat\s+permission\s+sets?\b.*\bassigned\b/
25735
+ /\bwhat\s+permission\s+sets?\b.*\bassigned\b/,
25736
+ // REACH (permissions/access cluster): "does/can the <Named> profile /
25737
+ // <Named> perm set have|give|grant|read|edit|create|delete|access|see|
25738
+ // change <object>". These name a SPECIFIC granter (a profile/permission
25739
+ // set) and ask what access it confers — the exact effective_permissions
25740
+ // ask, which no earlier rule caught (the existing templates required the
25741
+ // literal "effective/combined access" or the generic word "permissions").
25742
+ // Anchored on the interrogative verb `does|can` PLUS the granter noun
25743
+ // (profile|perm set) PLUS an access verb, all clause-bounded (`[^.?!]`) so
25744
+ // one sentence's verb can't reach across into the next. The `(?<!\bwhy\s)`
25745
+ // lookbehind keeps "why can't the X profile see the record" on
25746
+ // why-cant-see; "who can …" field asks (no does/can-led granter) stay on
25747
+ // field-access; enumerative "which permission sets grant …" stays on
25748
+ // object-access (no does/can lead). recordtype-availability and
25749
+ // profile-security sit EARLIER, so a record-type / session-security
25750
+ // phrasing still wins by first-match. The leading `^(?!.*\blayouts?\b)`
25751
+ // yields any "which layout does the X profile SEE" question to
25752
+ // layout-access (a later rule) — "layout" anywhere disqualifies this
25753
+ // permission route.
25754
+ /^(?!.*\blayouts?\b).*?(?<!\bwhy\s)\b(?:does|can)\b[^.?!]{0,60}\b(?:profile|perm\s*sets?|permission\s+sets?)\b[^.?!]{0,60}\b(?:have|give|gives?|grant|grants?|read|edit|create|delete|access|see|change)\b/,
25755
+ // "which perm sets are stacked on top of / assigned on top of the <Named>
25756
+ // profile" — a union-of-containers ask (what the stack effectively grants).
25757
+ /\bwhich\s+perm\s*sets?\b[^.?!]{0,40}\b(?:stacked|stack|on\s+top\s+of|added\s+to|layered)\b/,
25758
+ // "for <PermSetA>, <PermSetB>, <PermSetC> perm sets, what does each
25759
+ // contribute" — the division-of-access breakdown across a bundle.
25760
+ /\bperm\s*sets?\b[^.?!]{0,20},[^.?!]{0,80}\bwhat\s+does\s+each\s+(?:contribute|grant|add|allow)\b/
25519
25761
  ]
25520
25762
  },
25521
25763
  {
@@ -25578,7 +25820,16 @@ var init_intent_router = __esm({
25578
25820
  reason: "What happens when {Object}.{field} becomes {value} \u2014 the automation coupled to a value/stage transition, from the vault (lifecycle_process).",
25579
25821
  patterns: [
25580
25822
  /\bwhat\s+happens\s+when\b.*\b(becomes?|turns?|changes?\s+to|is\s+set\s+to|reaches?)\b/,
25581
- /\bwhat\s+(?:happens|runs|fires)\s+when\b.*\b(closed\s+won|closed\s+lost|converted|approved|activated)\b/,
25823
+ // Up to three optional adverb words between the verb and "when" — "what
25824
+ // runs AUTOMATICALLY when a Lead is converted" / "what fires IN THE
25825
+ // BACKGROUND when …" were unrouted (eval lifecycle family). The DML-event
25826
+ // save-order rule stays disjoint: its verb list has no transition verbs
25827
+ // (converted / closed won / approved), so nothing is stolen either way.
25828
+ /\bwhat\s+(?:happens|runs|fires)\b(?:\s+\w+){0,3}\s+when\b.*\b(closed\s+won|closed\s+lost|converted|approved|activated)\b/,
25829
+ // Nominalized transition — "what runs ON Lead CONVERSION?" / "what fires
25830
+ // upon Case escalation to closed" has no "when …is converted" clause at
25831
+ // all; the nominal "on/upon <Entity> conversion" form routes the same.
25832
+ /\bwhat\s+(?:happens|runs|fires|occurs|triggers)\b[^.?!]{0,40}\b(?:on|upon|during|after)\s+(?:an?\s+|the\s+)?\w+\s+conversion\b/,
25582
25833
  // P1e — generic state-transition verbs beyond the Opportunity/Lead
25583
25834
  // hardcoded list: "submitted", "disqualified", "completed", "enrolled" are
25584
25835
  // common transitions on other objects (Applications, Enrollments,
@@ -25607,7 +25858,21 @@ var init_intent_router = __esm({
25607
25858
  /\bwho\s+can\s+(see|access|view|edit|read)\b.*\b(all|every)\b.*\brecords?\b/,
25608
25859
  /\b(which|what)\s+(profiles?|permission\s+sets?|roles?|groups?)\b.*\b(see|access|edit)\b.*\brecords?\b/,
25609
25860
  // Object-level access without the word "records" — must beat field-access.
25610
- /\bwho\s+can\s+access\b(?!.*\bfield\b)/
25861
+ /\bwho\s+can\s+access\b(?!.*\bfield\b)/,
25862
+ // Enumerative SINGULAR phrasings — "list every profile with delete
25863
+ // permission on Contact", "show me every profile that can access Case".
25864
+ // The bare noun "profile" is an intent signal here, not a named entity;
25865
+ // these fell through to the generic schema list rule (eval family A).
25866
+ /\b(?:list|show)\b.*\bevery\s+profiles?\b.*\b(?:permission|access)/,
25867
+ /\bevery\s+profiles?\s+(?:that|who|with)\b.*\b(?:access|see|view|edit|read|delete|create)\b/,
25868
+ /\b(?:which|what)\s+profiles?\b.*\b(?:create|read|edit|delete|view)\s+permission\b/,
25869
+ // REACH (permissions/access cluster): "is <Object__c> visible / accessible
25870
+ // to the <Named> profile / perm set / role" — the forward object-record
25871
+ // access ask, which who_can_access_object answers (the agent reads whether
25872
+ // that container is among the granters). `__c`-anchored + "visible/…
25873
+ // to <container>" so it never grabs a layout ("is Account.Name visible on
25874
+ // the layout") or a schema ("is Payment__c an object") question.
25875
+ /\bis\b[^.?!]{0,20}\b\w+__c\b[^.?!]{0,20}\b(?:visible|accessible|available|readable|editable)\b[^.?!]{0,20}\bto\b[^.?!]{0,30}\b(?:profile|perm\s*sets?|permission\s+sets?|role|user)\b/
25611
25876
  ]
25612
25877
  },
25613
25878
  {
@@ -25620,7 +25885,20 @@ var init_intent_router = __esm({
25620
25885
  patterns: [
25621
25886
  /\bwhy\s+(can'?t|cannot|can\s+not)\b.*\b(see|view|access)\b.*\b(record|account|case|contact|lead|opportunity)\b/,
25622
25887
  /\bcan'?t\s+(see|view|access)\b.*\b(record|account|case|opportunity|contact|lead)\b/,
25623
- /\bwhy\s+(can'?t|cannot|can\s+not)\b.*\b(see|view|access)\b.*\b(an?\s+)?(account|case|contact|lead|opportunity)\b/
25888
+ /\bwhy\s+(can'?t|cannot|can\s+not)\b.*\b(see|view|access)\b.*\b(an?\s+)?(account|case|contact|lead|opportunity)\b/,
25889
+ // REACH (permissions/access cluster): the existing templates used a
25890
+ // SINGULAR `\brecord\b` and only see/view/access, so a plural "records"
25891
+ // ask ("why can't the Manager ROLE see Enrollment RECORDS", "why can't a
25892
+ // user EDIT Order__c RECORDS") fell through. Add plural
25893
+ // `records?` + the `edit|read` verbs — this is still the record-sharing
25894
+ // cascade (OWD → sharing → role hierarchy), the honest tool for a
25895
+ // "why can't X see/edit these RECORDS" question. The literal "records"
25896
+ // keeps it OFF field-access (a named FIELD, no "records").
25897
+ /\bwhy\s+(?:can'?t|won'?t|cannot|can\s+not)\b[^.?!]{0,60}\b(?:see|view|access|edit|read)\b[^.?!]{0,60}\brecords?\b/,
25898
+ // Negative-contrast visibility — "why can a <user> see A records BUT NOT
25899
+ // B" — the same sharing-cascade question phrased as a see-one-not-the-other
25900
+ // puzzle. The "but not" tail distinguishes it from a plain who-can-see.
25901
+ /\bwhy\s+can\b[^.?!]{0,60}\b(?:see|view|access)\b[^.?!]{0,60}\brecords?\b[^.?!]{0,40}\bbut\s+not\b/
25624
25902
  ]
25625
25903
  },
25626
25904
  {
@@ -25743,7 +26021,41 @@ var init_intent_router = __esm({
25743
26021
  // "field access audit for Email" / "field access for X" — "field access"
25744
26022
  // with the field named after (the (field|object)-after-access patterns
25745
26023
  // missed it). Battery gap.
25746
- /\bfield[-\s]access\b/
26024
+ /\bfield[-\s]access\b/,
26025
+ // Eval family C — qualifier hijack. "which fields are only ever written
26026
+ // BY an integration user" is a field WRITE-access audit (who can edit),
26027
+ // not integration topology: the "integration" qualifier was dragging it
26028
+ // onto integration_map.
26029
+ /\b(?:which|what)\s+fields?\b[^.?!]{0,80}\b(?:written|edited|updated|writable)\b[^.?!]{0,40}\bby\b/,
26030
+ // "compliance report … who touches the <X> field" — who-touches-a-field
26031
+ // is FLS edit access, not report run-history (the "report" qualifier was
26032
+ // dragging it onto reports-usage).
26033
+ /\bwho\s+touch(?:es)?\b[^.?!]{0,60}\b(?:fields?\b|__c\b)/,
26034
+ // REACH (permissions/access cluster) — FLS on a NAMED field:
26035
+ // (a) "who [adverb] can see/read/view/edit/access <Field__c or
26036
+ // Object.field>" — the existing who-can-see template broke on an
26037
+ // adverb ("who can ACTUALLY see Some_Field__c"). Field-anchored
26038
+ // (dotted or `__c`) and clause-guarded against "records" so a
26039
+ // who-can-see-RECORDS ask stays on who-can-access-object.
26040
+ /\bwho\s+can\b(?![^.?!]*\brecords?\b)[^.?!]{0,25}\b(?:see|read|view|edit|access)\b[^.?!]{0,40}(?:\b\w+\.\w+\b|\b\w+__c\b)/,
26041
+ // (b) "can/does <someone> edit/see <Object.field>" — FLS on a DOTTED
26042
+ // field ref ("Can Analytics edit Opportunity.Amount"). Restricted to a
26043
+ // dotted ref (or a bare `__c` accompanied by the word "field") so a
26044
+ // bare `__c` OBJECT ("can a user see Payment__c?") is NOT mistaken for
26045
+ // a field; the `(?<!\bwhy\s)` and no-"records" guards keep why-cant /
26046
+ // record asks off this rule, and effective-permissions (earlier) still
26047
+ // wins any granter-worded "does the X profile …" phrasing.
26048
+ /(?<!\bwhy\s)\b(?:can|does)\b(?![^.?!]*\brecords?\b)[^.?!]{0,40}\b(?:see|read|view|edit|access)\b[^.?!]{0,30}(?:\b\w+\.\w+\b|\b\w+__c\b[^.?!]{0,25}\bfields?\b|\bfields?\b[^.?!]{0,25}\b\w+__c\b)/,
26049
+ // (c) "why can't <someone> edit/see <Object.field or Field__c>" — an FLS
26050
+ // gap on a named FIELD (no "records"), which why_cant_user_see_record
26051
+ // (record sharing) does not answer; field_access_audit shows who holds
26052
+ // read/edit on the field. The no-"records" guard routes the RECORD
26053
+ // variant ("why can't X see <Object> records") to why-cant-see instead.
26054
+ // "access" is deliberately EXCLUDED from the verb list here — the noun
26055
+ // phrase "<managed-package> access" (in a "why can't they run the
26056
+ // managed-package action" question) would otherwise be misread as an
26057
+ // FLS verb.
26058
+ /\bwhy\s+can'?t\b(?![^.?!]*\brecords?\b)[^.?!]{0,40}\b(?:see|read|view|edit)\b[^.?!]{0,40}(?:\b\w+\.\w+\b|\b\w+__c\b)/
25747
26059
  ]
25748
26060
  },
25749
26061
  {
@@ -25763,6 +26075,27 @@ var init_intent_router = __esm({
25763
26075
  /\bdoes\b.*\bapex\b.*\b(enforce|CRUD|FLS)\b/
25764
26076
  ]
25765
26077
  },
26078
+ {
26079
+ // Deactivate-a-permission-set what-if (RESIDUAL 2). There is NO dedicated
26080
+ // what_if_* simulator for permission sets in the vault tier, so this is an
26081
+ // HONEST route to permission_risk_report (+ its impact edges via get_impact
26082
+ // once the set is resolved): the report surfaces what access the set grants
26083
+ // and who depends on it — the closest truthful answer to "does anything
26084
+ // break if we deactivate it". needsResolve so the named permission set is
26085
+ // resolved first. Sits before over-permission so a deactivation ask beats
26086
+ // the generic god-mode phrasing; requires the deactivate/turn-off verb + a
26087
+ // permission-set noun, so it never steals a plain over-privilege question.
26088
+ intent: "permission-set-deactivation-impact",
26089
+ plane: "vault",
26090
+ tools: ["sfi.resolve", "sfi.permission_risk_report", "sfi.get_impact"],
26091
+ liveRequired: false,
26092
+ needsResolve: true,
26093
+ reason: "No what_if simulator exists for permission sets, so this routes honestly to permission_risk_report (what the set grants + who depends on it) with get_impact for the dependency surface \u2014 the truthful stand-in for a deactivation blast radius, not a fabricated simulation.",
26094
+ patterns: [
26095
+ /\b(?:deactivat\w+|disabl\w+|turn(?:ed|ing)?\s+off|remov\w+|delet\w+)\b[^.?!]{0,60}\bpermission\s+sets?\b/,
26096
+ /\bpermission\s+sets?\b[^.?!]{0,60}\b(?:is|are|was|were|gets?|being)\s+(?:deactivated|disabled|turned\s+off|removed|deleted)\b/
26097
+ ]
26098
+ },
25766
26099
  {
25767
26100
  intent: "over-permission",
25768
26101
  plane: "vault",
@@ -25788,6 +26121,36 @@ var init_intent_router = __esm({
25788
26121
  /\bpermission\s+sets?\b.*\binstead\s+of\b.*\bprofiles?\b/
25789
26122
  ]
25790
26123
  },
26124
+ {
26125
+ // Profile login/session security — IP ranges ("IP relaxation"), login
26126
+ // hours, session settings (sfi.profile_security). Eval family C: the
26127
+ // "integration users" qualifier in "do any profiles have IP relaxation
26128
+ // that would block integration users" dragged this onto integration_map;
26129
+ // the head noun is the PROFILE security posture. Enumerative asks list
26130
+ // Profiles then drill per profile (profile_security requires a profileId).
26131
+ intent: "profile-security",
26132
+ plane: "vault",
26133
+ tools: ["sfi.list_components", "sfi.profile_security"],
26134
+ liveRequired: false,
26135
+ needsResolve: false,
26136
+ reason: "Profile login/session security (login IP ranges \u2014 'IP relaxation' \u2014 login hours, org session settings) is declared Profile metadata: list_components(type Profile) enumerates, then profile_security per profile reads the posture.",
26137
+ suggestArgs: () => ({ type: "Profile" }),
26138
+ patterns: [
26139
+ /\bip\s+(?:relaxation|relaxed|ranges?|restrictions?|whitelists?|allowlists?)\b/,
26140
+ /\blogin\s+(?:ip|hours?)\b/,
26141
+ /\bprofiles?\b[^.?!]{0,50}\b(?:session\s+(?:timeout|settings?)|login\s+restrictions?)\b/,
26142
+ // REACH (permissions/access cluster): MFA / password-policy / session
26143
+ // security compared ACROSS profiles ("which profiles have MFA or session
26144
+ // security settings weaker than the rest", "what password policies and
26145
+ // session timeout are set per profile"). Both word orders (profile→setting
26146
+ // and setting→profile). These are Profile login/session posture — the
26147
+ // profile_security surface — which the IP/login-hours templates above did
26148
+ // not cover. The `\bprofiles?\b` co-anchor keeps a generic "what is MFA"
26149
+ // knowledge question on guidance.
26150
+ /\bprofiles?\b[^.?!]{0,60}\b(?:mfa|multi[-\s]?factor|password\s+polic\w*|session\s+(?:security|timeout|settings?))\b/,
26151
+ /\b(?:mfa|multi[-\s]?factor|password\s+polic\w*|session\s+(?:security|timeout|settings?))\b[^.?!]{0,60}\bprofiles?\b/
26152
+ ]
26153
+ },
25791
26154
  {
25792
26155
  // EARLY PRECISION RULE (P14-ROUTER-safe-delete-misroute): a long
25793
26156
  // compound delete-verdict question enumerates nouns ("every layout,
@@ -25808,7 +26171,13 @@ var init_intent_router = __esm({
25808
26171
  patterns: [
25809
26172
  /\bsafe(?:[\s_-]+to[\s_-]+delete|_to_delete_field)\b/,
25810
26173
  /\b(block|prevent)\w*\b[^.?!]{0,30}\bdeletion\b/,
25811
- /\bbefore\s+deleting\b/
26174
+ /\bbefore\s+deleting\b/,
26175
+ // "can I SAFELY delete X, Y, Z or are they referenced somewhere" — the
26176
+ // adverb "safely" sits between "can i" and "delete", so the later
26177
+ // `can\s+i\s+delete` pattern misses it. The safe/safely + delete/remove
26178
+ // frame is the honest safe_to_delete_field ask (FIELD-FORENSICS REACH).
26179
+ /\bcan\s+i\s+safely\s+(delete|remove)\b/,
26180
+ /\bsafely\s+(delete|remove)\b[^.?!]{0,80}\breferenced\b/
25812
26181
  ]
25813
26182
  },
25814
26183
  {
@@ -25856,6 +26225,32 @@ var init_intent_router = __esm({
25856
26225
  /\bpermission\s+set\s+groups?\b[^.?!]{0,40}\b(no\s+one|nobody|unassigned|unused|assigned\s+to\s+nobody)\b/
25857
26226
  ]
25858
26227
  },
26228
+ {
26229
+ // HONEST GAP (eval family D): "which USERS have permission set X" is a
26230
+ // holder ROSTER — PermissionSetAssignment is runtime assignment data the
26231
+ // vault does not model, and no live tool answers it yet.
26232
+ // effective_permissions / object_access_audit describe what a permission
26233
+ // set GRANTS, not who HOLDS it, so substituting them would be confidently
26234
+ // wrong. Mirrors the unassigned-permset-groups gap above. The reverse
26235
+ // direction ("what permission sets does user X have") stays on
26236
+ // effective-permissions via first-match.
26237
+ intent: "permset-user-roster",
26238
+ plane: "vault",
26239
+ tools: [],
26240
+ liveRequired: false,
26241
+ needsResolve: false,
26242
+ reason: "Which users hold a permission set (PermissionSetAssignment) is not modeled \u2014 capability gap.",
26243
+ gap: {
26244
+ category: "permset-user-roster",
26245
+ note: 'PermissionSetAssignment modeling is not built yet, so "which users have permission set X" cannot be answered. Do not substitute effective_permissions or object_access_audit \u2014 they describe what a permission set GRANTS, not who holds it.'
26246
+ },
26247
+ patterns: [
26248
+ /\b(which|what|list)\s+users?\b[^.?!]{0,40}\b(have|hold|with|assigned)\b[^.?!]{0,40}\bpermission\s+sets?\b/,
26249
+ /\bwho\s+(has|holds|is\s+assigned)\b[^.?!]{0,50}\bpermission\s+sets?\b/,
26250
+ /\b(everyone|everybody|all\s+users?)\b[^.?!]{0,30}\bwith\b[^.?!]{0,40}\bpermission\s+sets?\b/,
26251
+ /\busers?\b[^.?!]{0,30}\bassigned\b[^.?!]{0,30}\bpermission\s+sets?\b/
26252
+ ]
26253
+ },
25859
26254
  {
25860
26255
  intent: "empty-queues-groups",
25861
26256
  plane: "vault",
@@ -25868,7 +26263,39 @@ var init_intent_router = __esm({
25868
26263
  /\b(queues?|public\s+groups?)\b.*\b(empty|no\s+members?|unused)\b/,
25869
26264
  /\b(which|what)\s+queues?\b.*\b(set\s+up|for|exist)\b/,
25870
26265
  /\bpublic\s+groups?\b.*\b(exist|who\s+is)\b/,
25871
- /\bwhat\s+public\s+groups?\b/
26266
+ /\bwhat\s+public\s+groups?\b/,
26267
+ // DISCOVERY/META REACH: routing-trap SYMPTOM questions. When work "isn't
26268
+ // getting picked up" / members "can't see cases routed to them", the
26269
+ // HONEST first check is whether the queue actually HAS members — exactly
26270
+ // what empty_queues_and_groups reports (memberCount / unknownMemberCount).
26271
+ // Every pattern REQUIRES the literal "queue(s)" AND a NEGATIVE/failure
26272
+ // frame (can't / not / isn't picked up / sitting in / exist-challenge),
26273
+ // so a neutral "which queues does X route to and who are the members"
26274
+ // stays a get_component ask (handled by the queue-membership rule below),
26275
+ // and a record-sharing ("why can't X see an Account") question — which
26276
+ // carries no "queue" — never lands here.
26277
+ // "why can (members of) <queue> NOT see … routed to them"
26278
+ new RegExp(`\\bqueues?\\b[^.?!]{0,80}\\b(?:can'?t|cannot|not\\s+(?:see|get|pick|able)|isn'?t|aren'?t)\\b`),
26279
+ // Queue named only by its `_Queue` API-name suffix (no standalone "queue"
26280
+ // word — underscore is a word char so `\bqueue\b` misses "ada_team_queue")
26281
+ // WITH a member/routing symptom. "why can members of <X>_Queue not see …
26282
+ // cases ROUTED to them" — the routing-trap membership check.
26283
+ // Failure-framed only ("NOT see", "can't", "isn't picked up") — a neutral
26284
+ // "which queues does <X>_Queue ROUTE to and who are the members" stays a
26285
+ // get_component ask (route/routed deliberately excluded here).
26286
+ /_queue\b[^.?!]{0,80}\b(?:not\s+(?:see|get|pick|able)|can'?t|cannot|isn'?t\s+(?:getting|picked))\b/,
26287
+ /\bmembers?\s+of\b[^.?!]{0,30}_queue\b[^.?!]{0,80}\b(?:not|can'?t|cannot)\b/,
26288
+ new RegExp(`\\b(?:can'?t|cannot|not\\s+(?:see|get|pick|able)|isn'?t|aren'?t)\\b[^.?!]{0,80}\\bqueues?\\b`),
26289
+ // "why is the Lead SITTING IN <queue> instead of getting PICKED UP —
26290
+ // queue members exist, right?" — the stuck-in-queue symptom.
26291
+ /\bsitting\s+in\b[^.?!]{0,40}\bqueues?\b/,
26292
+ /\bqueues?\b[^.?!]{0,40}\b(?:picked\s+up|getting\s+picked)\b/,
26293
+ /\bqueue\s+members?\b[^.?!]{0,30}\bexist\b/,
26294
+ // "why can't a user REASSIGN a Case to <Named>? They can touch every
26295
+ // other QUEUE" — reassignment-to-a-queue trouble; the membership /
26296
+ // queue-access check is the honest first probe. Requires a can't/cannot
26297
+ // failure frame co-occurring with "reassign" and "queue".
26298
+ new RegExp(`\\b(?:can'?t|cannot)\\b[^.?!]{0,40}\\breassign\\w*\\b.*\\bqueues?\\b`)
25872
26299
  ]
25873
26300
  },
25874
26301
  {
@@ -25921,7 +26348,12 @@ var init_intent_router = __esm({
25921
26348
  reason: "Profile\u2192permission-set migration / merge-split planning from permission metadata.",
25922
26349
  patterns: [
25923
26350
  /\bprofiles?\b.*\b(to|into|vs)\b.*\bpermission\s+sets?\b/,
25924
- /\b(merge|split|consolidate)\b.*\bprofiles?\b/,
26351
+ // `\bmerge\b` never matched the PAST tense "merged" (no boundary after
26352
+ // "merge"), so "what would break if I MERGED the A and B profiles" fell
26353
+ // through to unrouted. `merg\w*` catches merge/merged/merging; the two
26354
+ // named profiles + the plural "profiles" keep it precise (USAGE/impact
26355
+ // REACH — profiles/access family).
26356
+ /\b(merg\w*|split|splitting|consolidat\w*)\b.*\bprofiles?\b/,
25925
26357
  /\bprofile\s+migration\b/,
25926
26358
  // PASSIVE voice (P1c): "what if two profiles are merged / were consolidated"
25927
26359
  // — the active templates above ("if I merge profiles") missed the passive
@@ -25939,11 +26371,20 @@ var init_intent_router = __esm({
25939
26371
  liveRequired: false,
25940
26372
  needsResolve: true,
25941
26373
  reason: "Traces where a field flows (upstream/downstream) across Apex, flows, integrations.",
26374
+ // DEMOTED from catch-all (eval family E): field_lineage was the default
26375
+ // for any field-adjacent question carrying "flow(s)" or a bare
26376
+ // "upstream/downstream". Every pattern now requires an explicit
26377
+ // lineage/provenance/movement frame, so the earlier field-access /
26378
+ // explain-flow / save-order rules keep their heads and only genuine
26379
+ // "where does this data come from / flow to" questions land here.
25942
26380
  patterns: [
25943
26381
  /\b(data\s+flow|lineage)\b/,
25944
- /\bwhere\s+does\b.*\b(field|data|pii|it)\b.*\b(flow|go)\b/,
25945
- /\bfield\b.*\bflows?\b/,
25946
- /\b(upstream|downstream)\b/
26382
+ /\bwhere\s+does\b.*\b(field|data|pii|it)\b.*\b(flow|go|come\s+from)\b/,
26383
+ /\bfield\b[^.?!]{0,50}\bflows?\s+(?:to|into|out|through|downstream|between)\b/,
26384
+ // A change/delete/disable verb marks an IMPACT question, not lineage —
26385
+ // "what breaks downstream if I delete the X field" stays impact-analysis.
26386
+ /^(?!.*\b(?:break|delet|disabl|deactivat|remov|chang)\w*\b).*\b(upstream|downstream)\b[^.?!]{0,40}\b(?:fields?|data)\b/,
26387
+ /^(?!.*\b(?:break|delet|disabl|deactivat|remov|chang)\w*\b).*\b(?:fields?|data)\b[^.?!]{0,40}\b(upstream|downstream)\b/
25947
26388
  ]
25948
26389
  },
25949
26390
  {
@@ -26000,6 +26441,68 @@ var init_intent_router = __esm({
26000
26441
  ]
26001
26442
  },
26002
26443
  // === Automation / order-of-execution (vault) ==============================
26444
+ {
26445
+ // Disable-a-trigger what-if (eval family C): "blast radius if I disable
26446
+ // trigger T" was hijacked by qualifier words — "integration"/vendor-sync
26447
+ // onto integration_map, bare "downstream" onto field_lineage — when the
26448
+ // head question is the dedicated what_if_disable_trigger simulation.
26449
+ // Sits FIRST in the automation cluster so a disable ask beats the generic
26450
+ // trigger-order/automation-on-object phrasings; those carry no
26451
+ // disable/turn-off verb, so nothing is stolen from them.
26452
+ intent: "what-if-disable-trigger",
26453
+ plane: "vault",
26454
+ tools: ["sfi.resolve", "sfi.what_if_disable_trigger"],
26455
+ liveRequired: false,
26456
+ needsResolve: true,
26457
+ reason: "What stops firing / breaks if a named trigger is disabled \u2014 the dedicated what_if_disable_trigger simulation over the vault graph (not integration topology, not generic lineage).",
26458
+ patterns: [
26459
+ /\bdisabl\w+\b[^.?!]{0,40}\btriggers?\b/,
26460
+ /\btriggers?\b[^.?!]{0,40}\b(?:is|was|were|gets?|being)\s+disabled\b/,
26461
+ /\b(?:turn(?:ed|ing)?\s+off|switch(?:ed|ing)?\s+off)\b[^.?!]{0,40}\btriggers?\b/
26462
+ ]
26463
+ },
26464
+ {
26465
+ // Deactivate-a-flow what-if (RESIDUAL 2): "what breaks if I deactivate the
26466
+ // Onboarding flow" / "if I turn off FlowA and FlowB, does anything break".
26467
+ // The dedicated what_if_deactivate_flow simulator owns these — before it,
26468
+ // the deactivate-flow phrasing fell to generic impact-analysis (get_impact,
26469
+ // not the flow-specific simulator) and the "turn off … does anything break"
26470
+ // shape went unrouted entirely. Sits with the disable-trigger rule ahead of
26471
+ // trigger-order/automation-on-object: those carry no deactivate/turn-off
26472
+ // verb, so nothing is stolen from a save-order question. The deactivate/
26473
+ // turn-off verb + FLOW noun is the discriminator; needsResolve so the named
26474
+ // flow (or the first of several) is resolved before the simulation.
26475
+ intent: "what-if-deactivate-flow",
26476
+ plane: "vault",
26477
+ tools: ["sfi.resolve", "sfi.what_if_deactivate_flow"],
26478
+ liveRequired: false,
26479
+ needsResolve: true,
26480
+ reason: "What stops running / breaks if a named flow is deactivated \u2014 the dedicated what_if_deactivate_flow simulation over the vault graph (not a generic get_impact walk).",
26481
+ patterns: [
26482
+ /\b(?:deactivat\w+|disabl\w+|turn(?:ed|ing)?\s+off|switch(?:ed|ing)?\s+off)\b[^.?!]{0,60}\bflows?\b/,
26483
+ /\bflows?\b[^.?!]{0,60}\b(?:is|are|was|were|gets?|being)\s+(?:deactivated|disabled|turned\s+off)\b/,
26484
+ /\bflows?\b[^.?!]{0,60}\b(?:deactivat\w+|turn(?:ed|ing)?\s+off)\b/,
26485
+ // API-name form: "turn off Discount_Flow and Pricing_Flow, does anything
26486
+ // break" — the flow names carry the `_Flow` suffix rather than a standalone
26487
+ // "flow" word, so anchor on a deactivate/turn-off verb next to a *_Flow
26488
+ // component name (the whole-word "flow" alternations above miss this).
26489
+ /\b(?:deactivat\w+|disabl\w+|turn(?:ed|ing)?\s+off|switch(?:ed|ing)?\s+off)\b[^.?!]{0,40}\b[A-Za-z][A-Za-z0-9]*_Flow\b/i,
26490
+ // Flow-family API-name suffixes beyond `_Flow` (flow-family REACH): a
26491
+ // deactivate/turn-off verb next to a `_Process` / `_Orch` / `_Screen_Flow`
26492
+ // component name (e.g. Application_Save_RT_Orch). The SINGULAR flow-family
26493
+ // suffix (`_flow`, not the plural `_flows`) keeps the existing guard that a
26494
+ // plural embedded-Flow name like `ADA_Accom_Flow_Attribute_Flows` stays on
26495
+ // impact-analysis.
26496
+ /\b(?:deactivat\w+|disabl\w+|turn(?:ed|ing)?\s+off|switch(?:ed|ing)?\s+off)\b[^.?!]{0,60}\b[a-z][a-z0-9_]*_(?:process|orch|screen_flow)\b/i,
26497
+ // "what would happen if I deactivated <flow-suffix name>" — the explicit
26498
+ // what-if frame with a flow-family-named component.
26499
+ /\bwhat\s+would\s+happen\s+if\s+i\s+deactivat\w+\b[^.?!]{0,60}\b[a-z][a-z0-9_]*_(?:flow|process|orch|screen_flow)\b/i,
26500
+ // "suppose we deactivated <NamedComponent> versus <NamedComponent>" — the
26501
+ // "suppose … deactivated" lead is absent from the "what if we deactivate"
26502
+ // impact-analysis guard, so a >=2-underscore API name is safe here.
26503
+ new RegExp(`\\bsuppose\\s+we\\s+deactivat\\w+\\b[^.?!]{0,60}\\b${NAMED_COMPONENT_ID}\\b`)
26504
+ ]
26505
+ },
26003
26506
  {
26004
26507
  intent: "trigger-order",
26005
26508
  plane: "vault",
@@ -26016,7 +26519,9 @@ var init_intent_router = __esm({
26016
26519
  },
26017
26520
  patterns: [
26018
26521
  /\b(trigger\s+order|order\s+of\s+execution)\b/,
26019
- /\bwhat\s+(happens|runs|fires)\b.*\b(on\s+save|when\b.*\b(created|saved|updated|inserted|deleted|undeleted|restored))\b/,
26522
+ // One optional adverb between "what" and the verb — "what ACTUALLY
26523
+ // happens on save" fell through to unrouted (eval OVER-CLARIFY family A).
26524
+ /\bwhat\s+(?:\w+\s+)?(happens|runs|fires)\b.*\b(on\s+save|when\b.*\b(created|saved|updated|inserted|deleted|undeleted|restored))\b/,
26020
26525
  // "what happens when I update Contact" — present-tense DML without "on save"
26021
26526
  /\bwhat\s+(happens|runs|fires)\b.*\bwhen\b.*\b(i\s+)?(update|insert|delete|save|create|edit)\b/,
26022
26527
  // "which/what flows|triggers|VRs|workflows run|fire when ..." — the
@@ -26030,8 +26535,14 @@ var init_intent_router = __esm({
26030
26535
  // "what happens when a Case STATUS CHANGES" — a status/stage change is a
26031
26536
  // save-order event the "(created|updated|...)" verb list missed (B21).
26032
26537
  /\bwhat\s+(happens|runs|fires)\b.*\b(status|stage)\b.*\bchang/,
26033
- // "What runs on Account insert?" — DML event without "on save" / "when" (B21).
26034
- /\bwhat\s+runs\b.*\b(insert|update|delete|undelete)\b/,
26538
+ // "What runs on Account insert?" — DML event without "on save" / "when"
26539
+ // (B21). Verb-symmetric "fires"/"happens" too: "what FIRES on X insert
26540
+ // during the integration load — what runs bulk" is a save-order head
26541
+ // question; the bulk/load/integration qualifiers must not drag it onto
26542
+ // governor_limit_risks or integration_map (eval family C).
26543
+ // ("what happens IF I delete…" is an impact/what-if frame, not a DML
26544
+ // save-order ask — the immediate "if" is excluded so it falls through.)
26545
+ /\bwhat\s+(?:runs|fires|happens)\b(?!\s+if\b).*\b(insert|update|delete|undelete)\b/,
26035
26546
  // "what happens when I SAVE an Evaluation" — present-tense "save"/"saves"/
26036
26547
  // "saving" (the verb list above only had past-tense "saved"). Question-
26037
26548
  // battery gap.
@@ -26040,7 +26551,50 @@ var init_intent_router = __esm({
26040
26551
  /\bwhen\b.*\b(is\s+)?(inserted|updated|deleted|created)\b.*\b(trigger|fire)\b/,
26041
26552
  /\bdo\b[^.?!]{0,120}\b(trigger|triggers)\b[^.?!]{0,80}\bfire\b/,
26042
26553
  /\b(trigger|triggers)\b.*\b(both|and)\b.*\bfire\b/,
26043
- /\b(same\s+transaction|rollup)\b.*\b(after[-\s]?insert|DLRS|dlrs)/i
26554
+ /\b(same\s+transaction|rollup)\b.*\b(after[-\s]?insert|DLRS|dlrs)/i,
26555
+ // FULL SAVE-ORDER / whole-transaction reconstruction (flow-family REACH).
26556
+ // "walk me through everything that fires in order", "list every/all
26557
+ // automation that fires when …", "full save order on X", "before-vs-after
26558
+ // -save breakdown of every automation" — the whole-order ask, distinct
26559
+ // from lifecycle-process (transition-value coupled) which owns the
26560
+ // "…becomes/is set to <value>" shapes above it.
26561
+ /\b(everything|every\s+automation|all\s+(?:the\s+)?automation)\b[^.?!]{0,60}\b(fires?|runs?|happens?)\b/,
26562
+ /\b(fires?|runs?|happens?)\b[^.?!]{0,40}\bin\s+order\b/,
26563
+ /\b(list|give\s+me)\b[^.?!]{0,40}\b(?:every|all)\b[^.?!]{0,20}\bautomation\b[^.?!]{0,50}\b(fires?|runs?|when)\b/,
26564
+ /\bfull\s+save\s+order\b/,
26565
+ /\bsave\s+order\b[^.?!]{0,40}\b(?:on|for)\b/,
26566
+ /\bbefore[-\s]?(?:vs[-\s]?)?after[-\s]?save\b[^.?!]{0,40}\b(breakdown|every\s+automation|automation)\b/,
26567
+ // "what order do validation rules and record-triggered flows evaluate" —
26568
+ // an explicit ordering question over multiple automation families.
26569
+ /\bwhat\s+order\b[^.?!]{0,90}\b(evaluate|run|fire|execute)\b/,
26570
+ // "run order between <FlowA> and <flow>" — the pairwise ordering ask.
26571
+ /\brun\s+order\s+between\b/,
26572
+ // "which apex classes are triggered when a X is inserted/created" — the
26573
+ // "apex classes" phrasing the "(apex|code|class)" pattern above missed
26574
+ // because it requires the singular "runs" verb, not "are triggered".
26575
+ /\b(?:which|what)\s+apex\s+classes?\b[^.?!]{0,40}\b(triggered|fire|run)\b[^.?!]{0,40}\b(insert|update|delete|save|creat)/,
26576
+ // "does anything run on X update that would collide with <Flow>" — an
26577
+ // impact-on-save question framed as "does anything run … on <dml>".
26578
+ /\bdoes\s+anything\s+(run|fire|happen)\b[^.?!]{0,40}\b(update|insert|save|edit|creat)/,
26579
+ // "what else fires on the same X save transaction" — co-firing on the same
26580
+ // transaction as a named automation.
26581
+ /\bwhat\s+else\s+(fires?|runs?|happens?)\b[^.?!]{0,60}\bsave\s+transaction\b/,
26582
+ // "assignment rules … run before or after the record-triggered flows" — the
26583
+ // classic order-of-execution question about where assignment rules sit.
26584
+ /\bassignment\s+rules?\b[^.?!]{0,60}\b(before|after)\b[^.?!]{0,40}\b(?:record[-\s]?triggered\s+)?flows?\b/,
26585
+ // "will it fight/collide/conflict with HEDA" when ADDING an after-save flow
26586
+ // — the build-planning ask whose answer is the existing save-order.
26587
+ /\b(?:after[-\s]?save|before[-\s]?save)\s+flow\b[^.?!]{0,80}\b(fight|collide|conflict|clash)\b/,
26588
+ // ORDER-OF-EXECUTION: "which TDTM/handler/trigger classes fire on X insert
26589
+ // and in what order" — the ordered trigger-handler question (HEDA TDTM
26590
+ // handlers register per DML event). "in what order" is the discriminator.
26591
+ /\b(?:which|what)\b[^.?!]{0,60}\b(?:tdtm|handler|trigger)\s+classes?\b[^.?!]{0,50}\bfire\b[^.?!]{0,50}\b(?:in\s+what\s+order|what\s+order|order)\b/,
26592
+ // "does <NamedFlow> run before or after <the> lead conversion" — a pairwise
26593
+ // ordering ask relative to a conversion; order_of_execution reconstructs it.
26594
+ // (lifecycle-process owns "what runs WHEN a Lead is converted"; this "run
26595
+ // before/after conversion" shape carries no such "what happens when" head,
26596
+ // so the two stay disjoint.)
26597
+ /\bruns?\s+(?:before|after)\b[^.?!]{0,60}\b(?:lead\s+conversion|converts?\s+the\s+lead|conversion)\b/
26044
26598
  ]
26045
26599
  },
26046
26600
  {
@@ -26174,7 +26728,42 @@ var init_intent_router = __esm({
26174
26728
  /\bwhen\s+does\s+it\s+run\b/,
26175
26729
  /\bdoes\b.*\bflow\b.*\b(system\s+mode|without\s+sharing|with\s+sharing)\b/,
26176
26730
  /\b(system\s+mode|without\s+sharing|sharing\s+bypass)\b.*\bflow\b/,
26177
- /\b(run|runs|running)\b.*\b(system\s+mode|without\s+sharing)\b.*\bflow\b/
26731
+ /\b(run|runs|running)\b.*\b(system\s+mode|without\s+sharing)\b.*\bflow\b/,
26732
+ // NAMED-FLOW narration (flow-family REACH). A question that NAMES a
26733
+ // component by its API name (NAMED_COMPONENT_ID — a >=2-underscore token,
26734
+ // never English prose) AND asks to narrate/summarize/walk-through it is an
26735
+ // explain_flow ask. Each pattern REQUIRES the named id so a generic
26736
+ // "explain the sharing model" never lands here. resolve binds the entity
26737
+ // and the type-guard keeps explain_flow on a real Flow.
26738
+ // A COMPARE frame ("explain the difference between A and B", "compare …")
26739
+ // is NOT single-flow narration — explain_flow narrates ONE flow, so a
26740
+ // two-flow comparison must fall through (it stays a compare/unrouted gap
26741
+ // rather than a forced bad route). The `(?!…difference/compare/versus…)`
26742
+ // lookahead on the narration patterns below enforces that.
26743
+ new RegExp(`^(?!.*\\b(?:difference|differences|compare|comparison|versus|vs\\.?)\\b).*\\b(explain|summariz(?:e|ing)|walk\\s+me\\s+through|walkthrough|plain[-\\s]english\\s+walkthrough|purpose\\s+of)\\b[^.?!]{0,60}\\b${NAMED_COMPONENT_ID}\\b`),
26744
+ new RegExp(`\\b${NAMED_COMPONENT_ID}\\b[^.?!]{0,60}\\b(in\\s+plain\\s+(?:terms|english)|end\\s+to\\s+end|step\\s+by\\s+step)\\b`),
26745
+ // Narration verbs where the named id sits BEFORE the verb clause — e.g.
26746
+ // "Summarize <Name> and what it's calculating", "Explain <Name> and what
26747
+ // a 'flag' means", "What does <Name> copy from …". Same compare-frame
26748
+ // guard so "explain the difference between <A> and <B>" is not stolen.
26749
+ new RegExp(`^(?!.*\\b(?:difference|differences|compare|comparison|versus|vs\\.?)\\b).*\\b(?:explain|summarize|walk\\s+me\\s+through)\\b[^.?!]{0,20}\\b${NAMED_COMPONENT_ID}\\b`),
26750
+ new RegExp(`\\bwhat\\s+does\\b[^.?!]{0,20}\\b${NAMED_COMPONENT_ID}\\b[^.?!]{0,40}\\b(do|does|copy|copies|write|writes|calculat\\w*)\\b`),
26751
+ // "Does <Name> fire/run/write/have/send …" — a behavior question about a
26752
+ // NAMED flow (does it fire on insert+update, run every time, write to X,
26753
+ // have fault connectors, send texts). The named id as grammatical subject
26754
+ // of a flow-behavior verb keeps it precise.
26755
+ new RegExp(`\\bdoes\\s+(?:the\\s+)?${NAMED_COMPONENT_ID}\\b[^.?!]{0,80}\\b(fire|fires|run|runs|write|writes|have|has|send|sends|execute|re-?trigger)\\b`),
26756
+ // "Is <Name> a before-save/after-save/fast-field/scheduled/screen flow …" —
26757
+ // a flow-shape classification question about a named flow.
26758
+ new RegExp(`\\bis\\s+(?:the\\s+)?${NAMED_COMPONENT_ID}\\b[^.?!]{0,60}\\b(before[-\\s]?save|after[-\\s]?save|fast[-\\s]?field|scheduled\\s+path|screen\\s+flow|record[-\\s]?triggered)\\b`),
26759
+ // "What entry condition(s)/entry criteria gate/on <Name>" and "why would
26760
+ // <Name> skip a record" — entry-gate and skip-behavior narration.
26761
+ /\bentry\s+conditions?\b[^.?!]{0,20}\b(gate|on)\b/,
26762
+ /\bwhat(?:'s| is)?\s+the\s+entry\s+condition\b/,
26763
+ new RegExp(`\\bwhy\\s+would\\s+${NAMED_COMPONENT_ID}\\b[^.?!]{0,60}\\bskip\\b`),
26764
+ // "Does <Name> have any active version" / "why is it named like that" — the
26765
+ // active-version + naming question about a named flow.
26766
+ new RegExp(`\\bdoes\\s+${NAMED_COMPONENT_ID}\\b[^.?!]{0,40}\\b(active\\s+version|any\\s+active)\\b`)
26178
26767
  ]
26179
26768
  },
26180
26769
  {
@@ -26292,7 +26881,12 @@ var init_intent_router = __esm({
26292
26881
  /\b(which|what)\s+tests?\b.*\b(run|chang|modif|deploy|impact|diff|edit)/,
26293
26882
  /\btests?\b.*\bfor\s+(my|this|the|these)\s+(change|changes|diff|deploy|branch|pr|edit|edits)\b/,
26294
26883
  /\b(test\s+impact|impacted\s+tests?|test\s+selection|minimal\s+(set\s+of\s+)?tests?|test\s+subset)\b/,
26295
- /\bwhat\s+(do\s+i|should\s+i|to)\s+(run|test)\b.*\b(chang|deploy|diff)/
26884
+ /\bwhat\s+(do\s+i|should\s+i|to)\s+(run|test)\b.*\b(chang|deploy|diff)/,
26885
+ // "which/what test class COVERS <X> (and does it test the bulk case)" —
26886
+ // which tests exercise a named class is the tests_for_change call-graph
26887
+ // walk; the trailing "bulk" qualifier must not drag it onto
26888
+ // governor_limit_risks (eval family C).
26889
+ /\b(?:which|what)\s+test\s+class(?:es)?\b[^.?!]{0,20}\bcovers?\b/
26296
26890
  ]
26297
26891
  },
26298
26892
  {
@@ -26313,8 +26907,19 @@ var init_intent_router = __esm({
26313
26907
  /\b(fake|meaningless|empty|no\s+(real\s+)?)\s*assert/,
26314
26908
  /\btests?\b.*\bno\s+(real\s+)?assert/,
26315
26909
  /\bassertion\s+(quality|coverage)\b/,
26910
+ // "meaningful test audit on <X>" — the tool's own name as a natural-language
26911
+ // ask (USAGE/test-forensics REACH). Plus the classic no-op assertion tell
26912
+ // `System.assert(true)` / `assert(true)` that flags a rubber-stamp test.
26913
+ /\bmeaningful\s+test\s+audit\b/,
26914
+ /\bassert\w*\s*\(\s*true\s*\)/,
26316
26915
  /\b(less\s+than|below|under)\b.*\b\d+\s*%\b.*\bcoverage\b/,
26317
26916
  /\b(coverage|percent)\b.*\b(less\s+than|below|under)\b/,
26917
+ // "list apex classes below 75% coverage" — the `%\b` above never matches
26918
+ // "75% coverage" (%→space is not a word boundary), so the phrasing fell
26919
+ // through to the schema list rule and answered with get_component instead
26920
+ // of the coverage tools (eval family D).
26921
+ /\b(below|under|less\s+than)\s+\d+\s*(?:%|percent)?\s*(?:code\s+|test\s+)?coverage\b/,
26922
+ /\bcoverage\b[^.?!]{0,25}\b(below|under|less\s+than)\s+\d+/,
26318
26923
  /\bwhich\s+apex\s+classes\b.*\bcoverage\b/,
26319
26924
  /\bseealldata\s*=\s*true\b/i,
26320
26925
  /\bsee\s+all\s+data\b/i,
@@ -26360,7 +26965,12 @@ var init_intent_router = __esm({
26360
26965
  // "show performance risks in apex" — performance/scale risk phrasing the
26361
26966
  // governor-limit recognizer answers. Battery gap.
26362
26967
  /\bperformance\s+(risk|issue|problem|concern|bottleneck)s?\b/,
26363
- /\b(bulk|bulkif|unbounded)\b/,
26968
+ // "bulk" is a MODIFIER, not a head noun (eval family C): when the head
26969
+ // question is save-order ("what fires/runs/happens on X insert … what
26970
+ // runs bulk") or test coverage ("which test class covers Y … the bulk
26971
+ // case"), those earlier rules win by order — this guard keeps the bare
26972
+ // word from firing even when their patterns miss a phrasing.
26973
+ /^(?!.*\b(?:what\s+(?:fires|runs|happens)|test\s+class)\b).*\b(bulk|bulkif|unbounded)\b/,
26364
26974
  /\bcpu\s+time\b/,
26365
26975
  /\bheap\s+size\b/,
26366
26976
  /\bmost\s+dml\b/,
@@ -26406,7 +27016,14 @@ var init_intent_router = __esm({
26406
27016
  // extend the X package" — your customizations grafted onto a managed
26407
27017
  // package's objects (the package_impact extensionCount surface).
26408
27018
  /\b(what|which)\s+(of\s+(my|our)\s+)?(components?|metadata|customizations?|objects?|fields?)\b.*\bextends?\b/,
26409
- /\bextends?\b.*\b(package|namespace|managed)\b/
27019
+ /\bextends?\b.*\b(package|namespace|managed)\b/,
27020
+ // "what custom fields did <Package> INJECT across <objects>? Inventory for
27021
+ // UNINSTALL" — a managed-package boundary/uninstall inventory named by the
27022
+ // package rather than the literal word "package". The uninstall/inventory
27023
+ // frame + the inject/add-across verb keep it on package_impact and off the
27024
+ // generic field-usage tools (USAGE/IMPACT REACH — package boundary).
27025
+ /\b(inject\w*|add\w*|install\w*)\b[^.?!]{0,40}\bacross\b[^.?!]{0,60}\b(objects?|lead|contact|account|case|opportunity)\b[^]*\b(uninstall|inventory)\b/,
27026
+ /\binventory\s+for\s+uninstall\b/
26410
27027
  ]
26411
27028
  },
26412
27029
  {
@@ -26535,7 +27152,16 @@ var init_intent_router = __esm({
26535
27152
  /\brelease\s+readiness\b/,
26536
27153
  /\b(ready|readiness)\b.*\b(release|deploy|go[-\s]?live|cutover|production)\b/,
26537
27154
  /\bpre[-\s]?release\b.*\b(check|review|audit)\b/,
26538
- /\bgo[-\s]?live\b.*\b(risk|readiness|checklist)\b/
27155
+ /\bgo[-\s]?live\b.*\b(risk|readiness|checklist)\b/,
27156
+ // DISCOVERY/META REACH: "is this org release-ready for the summer push,
27157
+ // or are there blockers" — the hyphenated "release-ready" adjective and
27158
+ // the "blockers" framing the patterns above missed (they keyed on
27159
+ // "release readiness"/"ready … [to] release"). Co-anchored on
27160
+ // release/deploy/ship so a generic "are we ready for the demo" (no
27161
+ // release verb) does not match.
27162
+ /\brelease[-\s]?ready\b/,
27163
+ /\b(?:blockers?|showstoppers?)\b[^.?!]{0,40}\b(?:release|deploy|ship|go[-\s]?live|production|cutover)\b/,
27164
+ /\b(?:release|deploy|ship|go[-\s]?live|production|cutover)\b[^.?!]{0,40}\b(?:blockers?|showstoppers?)\b/
26539
27165
  ]
26540
27166
  },
26541
27167
  // === Vault health / freshness / coverage (vault) ==========================
@@ -26628,6 +27254,27 @@ var init_intent_router = __esm({
26628
27254
  /\bmetadata\b.*\b(documented|documentation)\b/
26629
27255
  ]
26630
27256
  },
27257
+ {
27258
+ // API-version hygiene ("any apex still on API version below 50?") was
27259
+ // unrouted — the schema rule only covers the single-component "what is the
27260
+ // api version of X" lookup (eval family D). tech_debt_score carries the
27261
+ // roll-up (apexBelowApiVersion30/40/50Count) and list_components(type:
27262
+ // ApexClass) enumerates the classes with each node's apiVersion.
27263
+ intent: "api-version-audit",
27264
+ plane: "vault",
27265
+ tools: ["sfi.tech_debt_score", "sfi.list_components"],
27266
+ liveRequired: false,
27267
+ needsResolve: false,
27268
+ reason: "Old-API-version inventory: tech_debt_score counts Apex below API version 30/40/50, and list_components(type: ApexClass) carries each class apiVersion.",
27269
+ suggestArgs: () => ({ type: "ApexClass" }),
27270
+ patterns: [
27271
+ /\bapi\s+versions?\b[^.?!]{0,25}\b(below|under|older|before|less\s+than)\b/,
27272
+ /\b(old|outdated|legacy|ancient)\b[^.?!]{0,15}\bapi\s+versions?\b/,
27273
+ /\b(apex|class(es)?|components?|metadata|flows?|triggers?)\b[^.?!]{0,40}\b(still\s+on|stuck\s+on|running\s+on)\b[^.?!]{0,15}\bapi\s+version/,
27274
+ /\b(upgrad|updat|bump)\w*\b[^.?!]{0,25}\bapi\s+versions?\b/,
27275
+ /\bapi\s+versions?\b[^.?!]{0,25}\b(upgrad|updat|bump)/
27276
+ ]
27277
+ },
26631
27278
  {
26632
27279
  // "Which classes implement <interface>" (Batchable, Schedulable, Queueable,
26633
27280
  // RestResource, ...) — grep Apex source for the implements clause. The
@@ -26657,7 +27304,32 @@ var init_intent_router = __esm({
26657
27304
  patterns: [
26658
27305
  /\bfind\b.*\b(class|apex|code)\b.*\b(mentions?|references?|uses?|calls?|reads?|writes?|with)\b/,
26659
27306
  /\b(which|what)\s+(classes?|apex)\b.*\b(mentions?|references?|uses?|touch(es)?|reads?|writes?|calls?|invokes?)\b/,
26660
- /\bsearch\b.*\b(apex|code)\b/
27307
+ /\bsearch\b.*\b(apex|code)\b/,
27308
+ // "which classes make HTTP callouts and what endpoints do they hit" —
27309
+ // HTTP callouts live in Apex SOURCE, so the answer is a source grep,
27310
+ // not the outbound endpoint catalog and never field lineage (eval
27311
+ // family C). "What endpoints do we call out to?" (no "callout" noun,
27312
+ // no HTTP) stays on the outbound endpoints catalog below.
27313
+ /\bhttp\s+callouts?\b/,
27314
+ /\bcallouts?\b[^.?!]{0,50}\bendpoints?\b/
27315
+ ]
27316
+ },
27317
+ {
27318
+ // "Does <the named class/job> run async — in its own transaction?" is a
27319
+ // question about ONE component's actual implementation (its @future /
27320
+ // Queueable / Batchable shape, read from its source via get_component),
27321
+ // not a best-practice lecture — the "async" qualifier was dragging it
27322
+ // onto the generic knowledge-plane guidance rule (eval family C).
27323
+ intent: "async-transaction-context",
27324
+ plane: "vault",
27325
+ tools: ["sfi.resolve", "sfi.get_component"],
27326
+ liveRequired: false,
27327
+ needsResolve: true,
27328
+ reason: "Whether a named component runs async in its own transaction is read from its OWN source/metadata (resolve it, then get_component shows the @future/Queueable/Batchable shape) \u2014 not generic async guidance.",
27329
+ patterns: [
27330
+ /\basync\w*\b[^.?!]{0,80}\bown\s+transaction\b/,
27331
+ /\bown\s+transaction\b[^.?!]{0,80}\basync\w*/,
27332
+ /\bin\s+its\s+own\s+transaction\b/
26661
27333
  ]
26662
27334
  },
26663
27335
  // === Integration (vault) ==================================================
@@ -26689,9 +27361,15 @@ var init_intent_router = __esm({
26689
27361
  liveRequired: false,
26690
27362
  needsResolve: false,
26691
27363
  reason: "Topology of the org's integration surfaces (named creds, connected apps, remote sites, external services).",
27364
+ // Guarded (eval family C — qualifier hijack): "integration"/vendor-sync
27365
+ // words are MODIFIERS when the head question is a what-if simulation
27366
+ // ("if the field type changed, what integrations blow up"), a blast
27367
+ // radius ("blast radius if I disable trigger T"), a field-write audit,
27368
+ // or profile IP security — those heads route earlier / later on their
27369
+ // own rules, so the bare noun must not force the topology catalog.
26692
27370
  patterns: [
26693
- /\b(integrations?|named\s+credentials?|connected\s+apps?|remote\s+sites?|external\s+services?|auth\s+providers?)\b/,
26694
- /\bwhat\b.*\bintegrat/,
27371
+ /^(?!.*\b(?:what\s+if|blast\s+radius|what\s+breaks|blows?\s+up|field\s+type|data\s+type|written\s+(?:only\s+)?by|only\s+ever\s+written|ip\s+relax\w*|disabl\w+)\b).*\b(integrations?|named\s+credentials?|connected\s+apps?|remote\s+sites?|external\s+services?|auth\s+providers?)\b/,
27372
+ /^(?!.*\b(?:what\s+if|blast\s+radius|what\s+breaks|blows?\s+up|field\s+type|data\s+type|written\s+(?:only\s+)?by|only\s+ever\s+written|ip\s+relax\w*|disabl\w+)\b).*\bwhat\b.*\bintegrat/,
26695
27373
  /\bapi\b.*\b(connections?|surfaces?)\b/
26696
27374
  ]
26697
27375
  },
@@ -26706,7 +27384,17 @@ var init_intent_router = __esm({
26706
27384
  /\b(platform\s+events?|change\s+data\s+capture|cdc)\b.*\b(subscrib|listen|consum)\b/,
26707
27385
  /\bwho\s+(subscribes?|listens?)\b/,
26708
27386
  /\b(subscribers?|subscriptions?)\b.*\bevents?\b/,
26709
- /\bshow\s+event\s+subscribers?\b/
27387
+ /\bshow\s+event\s+subscribers?\b/,
27388
+ // CDC fan-out / subscriber discovery (flow-family REACH). "what's
27389
+ // SUBSCRIBING TO Change Data Capture" (verb precedes the noun, so the
27390
+ // noun→verb pattern above misses it), "if turning on CDC for X will fan
27391
+ // out", and "CDC or platform-event subscribers feeding Marketo" (hyphenated
27392
+ // "platform-event" the space-only alternation above misses). Anchored to a
27393
+ // CDC/platform-event noun so a generic "who subscribes" is unaffected.
27394
+ /\b(subscrib\w*)\b[^.?!]{0,30}\b(change\s+data\s+capture|cdc|platform[-\s]?events?)\b/,
27395
+ /\b(turn\w*\s+on|enabl\w+)\s+cdc\b/,
27396
+ /\bcdc\b[^.?!]{0,40}\bfan\s+out\b/,
27397
+ /\b(cdc|change\s+data\s+capture|platform[-\s]?events?)\b[^.?!]{0,40}\bsubscribers?\b/
26710
27398
  ]
26711
27399
  },
26712
27400
  {
@@ -26867,7 +27555,15 @@ var init_intent_router = __esm({
26867
27555
  /\bdeactivating\b.*\b(break|would)\b/,
26868
27556
  /\breplacing\b.*\bwith\b.*\b(lwc|aura)\b/,
26869
27557
  /\baffected\b.*\b(removing|remove)\b.*\brecord\s+type/,
26870
- /\bwhat\s+would\s+break\b.*\b(changed|change)\b/
27558
+ /\bwhat\s+would\s+break\b.*\b(changed|change)\b/,
27559
+ // "what if I REMOVED the <X> record type … which page layouts and flows
27560
+ // assume it exists" — a record-type deletion blast radius (IMPACT REACH).
27561
+ // `\bremove\b` misses the PAST tense "removed", so this fell through to
27562
+ // unrouted. High-precision: the remove/delete verb must co-occur with the
27563
+ // literal "record type" AND a what-if/assume/kept frame, so it never steals
27564
+ // a field-type what-if (those say "field"/"required"/"picklist value").
27565
+ /\b(remov\w*|delet\w*|drop\w*)\b[^.?!]{0,40}\brecord\s+type\b[^]*\b(assume|assumes?|kept|keep|rely|relies|reference|expect)\w*/,
27566
+ /\bwhat\s+if\s+i\s+(remov\w*|delet\w*|drop\w*)\b[^.?!]{0,40}\brecord\s+type\b/
26871
27567
  ]
26872
27568
  },
26873
27569
  {
@@ -26886,7 +27582,32 @@ var init_intent_router = __esm({
26886
27582
  /\b(change|convert)\b.*\bfield\s+type\b/,
26887
27583
  /\b(change|changed)\b.*\bdata\s+type\b/,
26888
27584
  /\bdata\s+type\b.*\bchange/,
26889
- /\bremove\b.*\bpicklist\s+value\b/
27585
+ // PASSIVE / noun-first form — "if the field type CHANGED, what
27586
+ // integrations blow up": \bchange\b never matches "changed", so the
27587
+ // phrasing fell through and the "integrations" qualifier hijacked it
27588
+ // onto integration_map (eval family C).
27589
+ /\bfield\s+type\b[^.?!]{0,60}\bchang/,
27590
+ /\bremove\b.*\bpicklist\s+value\b/,
27591
+ // FIELD-FORENSICS REACH (what_if_make_field_required / _change_field_type).
27592
+ // The `\bmake\b` and `\bchange\b` verb anchors above miss the PAST tense
27593
+ // ("if we MADE X required", "if I CHANGED X") and the noun form
27594
+ // ("required-field validation"), so these fell to unrouted. Each new
27595
+ // pattern REQUIRES a required/field-type/picklist frame near the verb, so
27596
+ // it stays a schema what-if and never steals a value-change or usage ask.
27597
+ // "if we made <field> required" / "made X required but left Y optional".
27598
+ /\bmade\b[^.?!]{0,40}\brequired\b/,
27599
+ // "adding a required-field validation on <Field>" — making a field
27600
+ // mandatory expressed as a validation-rule ask; the required frame keeps
27601
+ // it on what_if_make_field_required.
27602
+ /\brequired[-\s]field\b/,
27603
+ /\b(add\w*|making|makes)\b[^.?!]{0,30}\brequired\b/,
27604
+ // "what happens if I change <field> FROM a picklist TO a text field" — a
27605
+ // field-TYPE conversion named by the from/to types (picklist→text) rather
27606
+ // than the literal "field type". The from-X-to-Y frame around a field-type
27607
+ // noun (picklist/text/number/checkbox/formula/lookup/date/currency) is
27608
+ // unambiguously a type change.
27609
+ new RegExp(`\\b(chang\\w*|convert\\w*)\\b[^.?!]{0,60}\\bfrom\\b[^.?!]{0,40}\\bto\\b[^.?!]{0,40}\\b(picklist|text|number|checkbox|formula|lookup|date|currency|multi[-\\s]?select)\\b`),
27610
+ new RegExp(`\\bfrom\\s+a\\s+picklist\\s+to\\s+a\\s+(?:text|number|formula|lookup|date|currency)\\b`)
26890
27611
  ]
26891
27612
  },
26892
27613
  {
@@ -27102,11 +27823,22 @@ var init_intent_router = __esm({
27102
27823
  tools: ["sfi.compare_vaults", "sfi.compare_object_across_vaults", "sfi.compare_profile_across_vaults"],
27103
27824
  liveRequired: false,
27104
27825
  needsResolve: false,
27105
- reason: "Differences between two registered orgs (UAT vs prod) from the vault registry.",
27826
+ reason: "Differences between two registered orgs (UAT vs prod) from the vault registry. Requires BOTH orgs to be registered vaults \u2014 the compare_* tools diff two offline snapshots, never the live org.",
27827
+ // DISCLOSURE, not a block (eval family D): on a single-vault install the
27828
+ // compare tools fail with vault-not-found — say the second-registered-vault
27829
+ // prerequisite up front instead of routing confident-clean into it.
27830
+ gap: {
27831
+ category: "cross-vault-registry",
27832
+ note: "Cross-vault comparison needs a SECOND registered vault (a multi-vault registry). If only this vault is registered, the compare_* call will return vault-not-found \u2014 register the other org first (sfi vault register) or name the two vault aliases to compare."
27833
+ },
27106
27834
  patterns: [
27107
27835
  /\b(uat|sandbox|staging)\b.*\b(vs|versus|compared?\s+to|and)\b.*\b(prod|production)\b/,
27108
27836
  /\bwhat('?s| is)\s+different\b.*\b(between|across)\b.*\borgs?\b/,
27109
- /\bcompare\b.*\borgs?\b/
27837
+ /\bcompare\b.*\borgs?\b/,
27838
+ // "compare the Account object across our sandboxes" — the cross-vault
27839
+ // ask phrased with "across" + an environment noun (eval family D).
27840
+ /\bcompare\b[^.?!]{0,60}\bacross\b[^.?!]{0,30}\b(orgs?|sandbox(es)?|environments?|vaults?|instances?)\b/,
27841
+ /\b(differs?|difference|different)\b[^.?!]{0,50}\b(between|across)\b[^.?!]{0,40}\b(sandbox(es)?|environments?|instances?|vaults?)\b/
27110
27842
  ]
27111
27843
  },
27112
27844
  {
@@ -27229,7 +27961,115 @@ var init_intent_router = __esm({
27229
27961
  /\b(ci\/cd|source\s+control|deployment\s+(and\s+)?release)\b/,
27230
27962
  /\bsandboxes?\b.*\b(refresh|managed)\b/,
27231
27963
  /\benvironment\s+and\s+release\s+strategy\b/,
27232
- /\b(is\s+there\s+a\s+)?ci\/cd\b/
27964
+ /\b(is\s+there\s+a\s+)?ci\/cd\b/,
27965
+ // Concept ask about an access primitive — "What is a Profile" / "what is
27966
+ // a permission set". The indefinite article marks a GENERIC type word,
27967
+ // never a named component, so this must not fall through to unrouted (or
27968
+ // worse, a Profile-record disambiguation menu). Comparisons ("difference
27969
+ // between a profile and a permission set") stay on compare-profiles.
27970
+ /\bwhat\s+is\s+an?\s+(?:profile|permission\s+set)\s*\??\s*$/
27971
+ ]
27972
+ },
27973
+ // === FIELD-FORENSICS REACH block (USAGE / IMPACT / FIELD-FORENSICS cluster) =
27974
+ // These sit BEFORE the generic component-usage dispatcher so a question that
27975
+ // NAMES a specific field (NAMED_FIELD_ID — a dotted `Object.field` or a bare
27976
+ // `__c` api name) and asks a field-forensics question lands on the dedicated
27977
+ // field tool instead of the generic usage tool. Every pattern REQUIRES the
27978
+ // named field, so a bare-English question (no dot, no `__c`) never fires here
27979
+ // and keeps its existing route. The order within the block encodes the eval's
27980
+ // distinction: "reads OR writes" → find_field_anywhere; "what writes … is it a
27981
+ // flow" (writers only) → field_provenance; a lineage/trace frame →
27982
+ // field_lineage; "field 360" → field_360; a semantic "do we have a field for
27983
+ // X" → find_semantic_field. USAGE ("which flows write X", "is X triggered by
27984
+ // Y", "connected to Marketo") stays on the component-usage dispatcher below.
27985
+ {
27986
+ // "do we already have a field for <concept>" / "where do we store <concept>
27987
+ // data across the org" — semantic field discovery. High-precision on the
27988
+ // store/have-a-field frame + a cross-org scope; must sit before the field-id
27989
+ // rules because it is about a CONCEPT, not a specific named field (the named
27990
+ // field it cites, e.g. "X is one, what ELSE", is an example, not the target).
27991
+ intent: "find-semantic-field",
27992
+ plane: "vault",
27993
+ tools: ["sfi.find_semantic_field"],
27994
+ liveRequired: false,
27995
+ needsResolve: false,
27996
+ reason: 'Semantic field discovery \u2014 "do we already have a field for X" ranks CustomFields by token overlap with a natural-language concept (heuristic recommendation).',
27997
+ patterns: [
27998
+ /\bwhere\s+do\s+we\s+store\b[^.?!]{0,60}\b(across|anywhere|in\s+(?:the\s+)?org)\b/,
27999
+ /\bdo\s+we\s+(?:already\s+)?have\s+a\s+field\s+for\b/,
28000
+ /\b(is|are)\s+there\s+(?:already\s+)?an?\s+(?:existing\s+)?field\s+for\b/,
28001
+ // "X is one, what else" — the caller names one example field and asks for
28002
+ // the rest of the concept family (the semantic-discovery signature).
28003
+ /\bis\s+one,?\s+what\s+else\b/
28004
+ ]
28005
+ },
28006
+ {
28007
+ // "trace where <field> goes after conversion / where does <field> data come
28008
+ // from / where does <field> flow" — data lineage for a NAMED field. Sits
28009
+ // before find_field_anywhere/provenance so an explicit trace/lineage frame
28010
+ // wins over a bare reads/writes ask. The named-field id + a movement verb
28011
+ // (trace/goes/lands/flows/come from) keep it precise; the pii-flow rule
28012
+ // earlier already owns the "field data flow/lineage" phrasings without a
28013
+ // named id, so this only adds the NAMED-field trace shape it missed.
28014
+ intent: "field-lineage",
28015
+ plane: "vault",
28016
+ tools: ["sfi.resolve", "sfi.field_lineage"],
28017
+ liveRequired: false,
28018
+ needsResolve: true,
28019
+ reason: "Trace where a NAMED field's value comes from and what it feeds \u2014 the provenance + downstream-effects walker, requested by an explicit trace/lineage frame on a named field.",
28020
+ patterns: [
28021
+ new RegExp(`\\btrace\\b[^.?!]{0,40}\\b${NAMED_FIELD_ID}\\b[^.?!]{0,60}\\b(goes?|lands?|flows?|end\\s+up|get\\s+dropped|come\\s+from|after\\s+conversion)\\b`),
28022
+ new RegExp(`\\bwhere\\s+does\\b[^.?!]{0,30}\\b${NAMED_FIELD_ID}\\b[^.?!]{0,50}\\b(go|goes?|flow|flows?|come\\s+from|land)\\b`)
28023
+ ]
28024
+ },
28025
+ {
28026
+ // "give me the field 360 on <Field>" / "the full picture of <Field>" — the
28027
+ // unified field-forensics synthesis tool, requested by its own vocabulary.
28028
+ // The literal "field 360" / "360 on <field>" phrase is unambiguous.
28029
+ intent: "field-360",
28030
+ plane: "vault",
28031
+ tools: ["sfi.resolve", "sfi.field_360", "sfi.explain_field"],
28032
+ liveRequired: false,
28033
+ needsResolve: true,
28034
+ reason: "Full 360 profile of a single field \u2014 everything that validates / writes / reads / uses it across automation, code, UI, integrations, composed into one report.",
28035
+ patterns: [
28036
+ /\bfield\s*360\b/,
28037
+ /\b360\b[^.?!]{0,20}\b(on|of|for)\b[^.?!]{0,20}(?:[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|[a-z][a-z0-9_]*__c)\b/,
28038
+ new RegExp(`\\b(fulls+(?:picture|profile)|everythings+thats+(?:touches|uses))\\b[^.?!]{0,30}\\b${NAMED_FIELD_ID}\\b`)
28039
+ ]
28040
+ },
28041
+ {
28042
+ // "What READS OR WRITES <Field> on <Object>" — the universal find-anywhere
28043
+ // for a named field (both directions). The "reads or/and writes" (or the
28044
+ // "used anywhere") frame distinguishes it from provenance (writers-only)
28045
+ // below. Requires the named field id so bare English never fires.
28046
+ intent: "find-field-anywhere-usage",
28047
+ plane: "vault",
28048
+ tools: ["sfi.resolve", "sfi.find_field_anywhere"],
28049
+ liveRequired: false,
28050
+ needsResolve: true,
28051
+ reason: "Where a NAMED field is used anywhere \u2014 every incoming edge (apex reads/writes, flow ops, layout placements, VR refs), grouped by referrer type. Both read and write directions.",
28052
+ patterns: [
28053
+ new RegExp(`\\b(reads?\\s+(?:or|and)\\s+writes?|writes?\\s+(?:or|and)\\s+reads?)\\b[^.?!]{0,60}\\b${NAMED_FIELD_ID}\\b`),
28054
+ new RegExp(`\\b${NAMED_FIELD_ID}\\b[^.?!]{0,40}\\bused\\s+anywhere\\b`)
28055
+ ]
28056
+ },
28057
+ {
28058
+ // "What WRITES <Field> on <Object> — is it a flow?" — the source-of-a-field
28059
+ // ask (writers only): who/what SETS this field's value, and is it manual /
28060
+ // automated / integration-synced. Distinguished from find-field-anywhere
28061
+ // above by being writers-only (no "reads"), and from the "which flows write
28062
+ // X" USAGE ask below by NOT scoping to a component type up front (the field
28063
+ // is the subject: "what writes X"). Requires the named field id.
28064
+ intent: "field-provenance",
28065
+ plane: "vault",
28066
+ tools: ["sfi.resolve", "sfi.field_provenance"],
28067
+ liveRequired: false,
28068
+ needsResolve: true,
28069
+ reason: "Who/what SETS a field's value \u2014 the writers fabric (apex/flow/trigger writers, formula/auto-number declaration, integration-synced classifier) for a named field.",
28070
+ patterns: [
28071
+ new RegExp(`^(?!.*\\breads?\\b)(?!.*\\bwhich\\s+flows?\\b).*\\bwhat\\s+writes\\b[^.?!]{0,80}\\b${NAMED_FIELD_ID}\\b`),
28072
+ new RegExp(`\\bwhat\\s+(?:sets|populates?|fills?)\\b[^.?!]{0,60}\\b${NAMED_FIELD_ID}\\b[^.?!]{0,40}\\bis\\s+it\\s+(?:a\\s+)?(?:flow|manual|automated|integration)`)
27233
28073
  ]
27234
28074
  },
27235
28075
  {
@@ -27260,7 +28100,33 @@ var init_intent_router = __esm({
27260
28100
  // usage lands here.
27261
28101
  /\bwhere\s+is\b(?![^?]*\bfield\b)(?![^?]*__c)[^?]*\b(used|referenced|consumed)\b/,
27262
28102
  /\b(what|who|which\s+components?)\b.*\b(uses?|references?|depends?\s+on|consumes?)\b/,
27263
- /\bwhat\s+still\s+references\b/
28103
+ /\bwhat\s+still\s+references\b/,
28104
+ // USAGE REACH (find_component_usages). "Which flows WRITE (to) <field>" —
28105
+ // the writers of a field scoped to a component TYPE (flows). Distinct from
28106
+ // field_provenance ("what writes X") by naming the referrer type first;
28107
+ // find_component_usages is the right dispatcher for "which <type> touch X".
28108
+ /\bwhich\s+(?:flows?|classes?|triggers?|automations?|processes?)\b[^.?!]{0,20}\b(writes?|write\s+to|updates?|reads?|references?|touch\w*|sets?)\b/,
28109
+ // "which flow SHOULD (do X / update Y) and why isn't it running" — a
28110
+ // which-component-does-this usage lookup phrased as a troubleshooting ask.
28111
+ /\bwhich\s+(?:flow|class|automation|process)\b[^.?!]{0,30}\bshould\b/,
28112
+ // "is <NamedFlow> triggered by <OtherFlow> or standalone" — whether one
28113
+ // component invokes another (an incoming-edge usage question).
28114
+ /\bis\s+(?:the\s+)?[a-z][a-z0-9_]*_[a-z0-9_]+\b[^.?!]{0,30}\btriggered\s+by\b/,
28115
+ // "does updating <field> trigger any <X> automation" — whether writing a
28116
+ // field fires downstream automation (the field's incoming/outgoing usage).
28117
+ // `[^?!]` (not `[^.?!]`) so the gap can span the dotted `Object.field__c`
28118
+ // name, whose `.` the standard class would otherwise stop at.
28119
+ /\bdoes\s+updating\b[^?!]{0,50}\btrigger\b[^?!]{0,30}\bautomation\b/,
28120
+ // "is <object> connected to <ExternalSystem>" — integration-usage of a
28121
+ // component; find_component_usages composes graph edges + the integration
28122
+ // map (the rule already carries integration_map as a secondary tool).
28123
+ /\b(is|are)\b[^.?!]{0,40}\b(connected\s+to|integrated\s+with|synced?\s+(?:to|with)|feeding)\b[^.?!]{0,30}\b(marketo|pardot|hubspot|external|api)\b/,
28124
+ // "is <NamedComponent> even/still needed anymore BASED ON USAGE" — a
28125
+ // still-in-use check on a named component, answered by walking its usage
28126
+ // edges. The "based on usage" / "still used anywhere" frame is the tell;
28127
+ // it keeps this off the cleanup-catalog tools (which take no named id).
28128
+ /\b(?:even|still)\s+(?:needed|used|referenced)\b[^?!]{0,20}\b(?:anymore\s+)?based\s+on\s+usage\b/,
28129
+ /\bstill\s+(?:used|referenced)\s+anywhere\b/
27264
28130
  ]
27265
28131
  },
27266
28132
  {
@@ -27476,6 +28342,121 @@ var init_intent_router = __esm({
27476
28342
  /\bcompare\b.*\b(permission\s+sets?|profiles?)\b/
27477
28343
  ]
27478
28344
  },
28345
+ {
28346
+ // DISCOVERY/META REACH: "compare A and B" / "explain the difference between
28347
+ // A and B" where A and B are TWO NAMED components (permission-set groups,
28348
+ // flows, classes) — a two-component diff, which compare_components answers.
28349
+ // Sits AFTER compare-profiles (that owns the "profiles"/"permission sets"
28350
+ // wording) and AFTER explain-flow (whose narration patterns carry a
28351
+ // compare-frame negative-lookahead, so a two-flow "difference between A and
28352
+ // B" deliberately falls through to HERE rather than being narrated as one
28353
+ // flow). Precision: every pattern requires TWO distinct named tokens joined
28354
+ // by "and"/"vs"/"versus" (or the explicit plural "PSGs"/"perm groups"), so
28355
+ // a single-component "explain X" never lands here.
28356
+ intent: "compare-components",
28357
+ plane: "vault",
28358
+ tools: ["sfi.resolve", "sfi.compare_components"],
28359
+ liveRequired: false,
28360
+ needsResolve: true,
28361
+ reason: "Comparing two named components is a vault diff \u2014 resolve both, then compare_components.",
28362
+ patterns: [
28363
+ // "compare the <NamedA> and <NamedB> PSGs" / "… perm groups" — an
28364
+ // explicit two-thing compare of permission-set GROUPS (compare-profiles
28365
+ // keys on "profiles"/"permission sets", not the "PSG"/"perm group" plural).
28366
+ new RegExp(`\\bcompare\\b[^.?!]{0,80}\\b(?:psgs?|perm(?:ission)?\\s+set\\s+groups?|perm\\s+groups?)\\b`),
28367
+ new RegExp(`\\b(?:psgs?|perm(?:ission)?\\s+set\\s+groups?|perm\\s+groups?)\\b[^.?!]{0,80}\\b(?:compare|difference|differ|versus|vs\\.?)\\b`),
28368
+ // "compare/difference between <NamedA> and <NamedB>" — two API-name tokens
28369
+ // (>=2 underscores each: unambiguously component ids, never prose) joined
28370
+ // by "and"/"&". The two-id requirement is what distinguishes a real
28371
+ // comparison from a single-component narration. The leading
28372
+ // `(?!.*record\s+types?)` yields a "difference between A and B and C record
28373
+ // TYPES" enumeration to record-type-enumeration below (list all types),
28374
+ // which is the honest surface for several record types side by side.
28375
+ new RegExp(`^(?!.*\\brecord\\s+types?\\b).*\\b(?:compare|difference|differ|versus|vs\\.?)\\b[^.?!]{0,40}\\b${NAMED_COMPONENT_ID}\\b[^.?!]{0,20}\\b(?:and|&|versus|vs\\.?)\\b[^.?!]{0,20}\\b${NAMED_COMPONENT_ID}\\b`)
28376
+ ]
28377
+ },
28378
+ {
28379
+ // DISCOVERY/META REACH: "does <NamedFlow> relate to the <concept> concept" /
28380
+ // "is <A> the same as <B>" — an org-vocabulary disambiguation, which
28381
+ // disambiguate_concepts answers (are these two tokens the same or distinct
28382
+ // concepts here). Anchored on the literal "concept(s)" noun OR the "same
28383
+ // as … thing/idea" frame so it never grabs a compare (two components) or a
28384
+ // field-lineage ("where does X go") question.
28385
+ intent: "disambiguate-concepts-nl",
28386
+ plane: "vault",
28387
+ tools: ["sfi.disambiguate_concepts"],
28388
+ liveRequired: false,
28389
+ needsResolve: false,
28390
+ reason: "Whether two org-specific concepts/terms mean the same thing is a vocabulary disambiguation (disambiguate_concepts).",
28391
+ patterns: [
28392
+ /\b(?:relate\s+to|related\s+to|same\s+as|different\s+from|distinct\s+from|the\s+same\s+thing)\b[^.?!]{0,50}\bconcepts?\b/,
28393
+ /\bconcepts?\b[^.?!]{0,50}\b(?:relate\s+to|related\s+to|same\s+as|different\s+from|distinct\s+from)\b/,
28394
+ /\bis\b[^.?!]{0,30}\bthe\s+same\s+(?:as|thing\s+as|concept\s+as)\b/
28395
+ ]
28396
+ },
28397
+ {
28398
+ // DISCOVERY/META REACH: "which queues does <Named>_Queue route to and who
28399
+ // are the MEMBERS" — a NEUTRAL single-queue inspection (no failure frame),
28400
+ // which get_component renders (the Queue node carries members + routing).
28401
+ // resolve binds the named queue first. Distinct from empty-queues above,
28402
+ // which only fires on a can't/stuck SYMPTOM. Anchored on a `_queue`
28403
+ // API-name suffix (or the literal "queue" + "members") so it never grabs a
28404
+ // schema-inventory ("list all queues") ask.
28405
+ intent: "queue-membership",
28406
+ plane: "vault",
28407
+ tools: ["sfi.resolve", "sfi.get_component"],
28408
+ liveRequired: false,
28409
+ needsResolve: true,
28410
+ reason: "A single queue's members and routing targets are on its Queue node \u2014 resolve the queue, then get_component.",
28411
+ patterns: [
28412
+ /\bwho\s+are\s+the\s+members?\b[^.?!]{0,60}\bqueues?\b/,
28413
+ /\bqueues?\b[^.?!]{0,60}\bwho\s+are\s+the\s+members?\b/,
28414
+ /\bwhich\s+queues?\b[^.?!]{0,40}\broute\b/
28415
+ ]
28416
+ },
28417
+ {
28418
+ // DISCOVERY/META REACH: "what's the API version on the TDTM handlers / these
28419
+ // HEDA classes" — the per-class apiVersion is a get_component field. The
28420
+ // existing schema-rule api-version patterns cover the SINGULAR
28421
+ // "api version of X class" (Family-D contract) but missed the PLURAL
28422
+ // "handlers"/"classes" scan. Requires the api-version phrase with a PLURAL
28423
+ // handlers/classes/triggers noun (mandatory `s`) so the singular
28424
+ // "what is the api version of the AccountService class" stays on schema and
28425
+ // a plain "what version are we on" (org release) never matches.
28426
+ intent: "component-api-version",
28427
+ plane: "vault",
28428
+ tools: ["sfi.resolve", "sfi.list_components", "sfi.get_component"],
28429
+ liveRequired: false,
28430
+ needsResolve: true,
28431
+ reason: "The api version across a set of Apex classes / triggers / handlers is a per-component property scan \u2014 list_components then get_component per member.",
28432
+ patterns: [
28433
+ /\bapi\s+version\b[^.?!]{0,60}\b(?:classes|handlers|triggers)\b/,
28434
+ /\b(?:classes|handlers|triggers)\b[^.?!]{0,60}\bapi\s+version\b/
28435
+ ]
28436
+ },
28437
+ {
28438
+ // DISCOVERY/META REACH: "explain the difference between <RT_A> and <RT_B>
28439
+ // and <RT_C> record types" — an enumeration of MULTIPLE record types on an
28440
+ // object; list_components(type: 'RecordType') is the honest surface (the
28441
+ // user wants each type side by side, not a pairwise diff). Requires the
28442
+ // literal "record types" plural AND at least the "difference"/"vs" framing
28443
+ // over 3+ named types joined by "and" — so a two-thing "compare A and B"
28444
+ // (handled by compare-components) and a single "what is the X record type"
28445
+ // do not land here.
28446
+ intent: "record-type-enumeration",
28447
+ plane: "vault",
28448
+ tools: ["sfi.resolve", "sfi.list_components", "sfi.get_component"],
28449
+ liveRequired: false,
28450
+ needsResolve: true,
28451
+ reason: "Distinguishing several record types on an object is a vault enumeration \u2014 list_components(RecordType) plus get_component per type.",
28452
+ suggestArgs: () => ({ type: "RecordType" }),
28453
+ patterns: [
28454
+ // "difference between A and B and C record types" — 3+ names joined by
28455
+ // "and", trailing "record types". The two "and"-joins are the tell that
28456
+ // this is a multi-type enumeration, not a pairwise compare.
28457
+ /\b(?:difference|differ|distinguish|compare)\b[^.?!]{0,120}\band\b[^.?!]{0,60}\band\b[^.?!]{0,60}\brecord\s+types?\b/
28458
+ ]
28459
+ },
27479
28460
  {
27480
28461
  intent: "capabilities",
27481
28462
  plane: "vault",
@@ -27487,7 +28468,21 @@ var init_intent_router = __esm({
27487
28468
  /\bwhat\s+can\s+you\s+do\b/,
27488
28469
  /\bwhat\s+are\s+you\s+capable\b/,
27489
28470
  /\bwhat\s+tools?\s+(do\s+you\s+have|are\s+available)\b/,
27490
- /\bhelp\b.*\b(capabilit|tools?|commands?)\b/
28471
+ /\bhelp\b.*\b(capabilit|tools?|commands?)\b/,
28472
+ // DISCOVERY/META REACH: "what are you actually able to answer about this
28473
+ // org", "what can I ask" — a self-capability ask. `able to answer` /
28474
+ // `can .. ask` co-anchored so it never grabs an org-content question
28475
+ // ("what can this profile do"): the subject is the tool ("you"/"I"/"this"),
28476
+ // not an org component.
28477
+ /\bwhat\b[^.?!]{0,30}\b(?:you|i)\b[^.?!]{0,20}\b(?:able\s+to\s+answer|answer\s+about|ask\s+about|ask\s+you)\b/,
28478
+ /\bwhat\s+can\s+i\s+ask\b/,
28479
+ // "can you (even) tell me anything about … record data … or is this just
28480
+ // metadata" — a boundary/capability probe. `just metadata` / `only
28481
+ // metadata` is the capability-boundary tell (capabilities reports the
28482
+ // metadata-vs-live-record boundary); it never appears in a real
28483
+ // org-content question.
28484
+ /\b(?:just|only)\s+metadata\b/,
28485
+ /\bcan\s+you\s+(?:even\s+)?tell\s+me\s+anything\s+about\b[^.?!]{0,40}\b(?:record\s+data|data\s+in\s+here)\b/
27491
28486
  ]
27492
28487
  },
27493
28488
  {
@@ -35219,7 +36214,7 @@ var init_effective_permissions = __esm({
35219
36214
  };
35220
36215
  BASE_DISCLOSURES = Object.freeze([
35221
36216
  "Permission-set GROUP membership IS expanded: a PermissionSetGroup passed in `permissionSetIds` is unioned into its member permission sets (declared metadata). Muting permission sets are DISCLOSED but NOT subtracted \u2014 a group with a muting set may confer LESS than shown.",
35222
- "App and tab visibility are a separate surface (now extracted \u2014 see `app_access` / `tab_availability`); they are not part of this permission union, which composes object / field / Apex / system permissions.",
36217
+ "App and tab visibility are a separate surface (now extracted \u2014 see `app_access` / `tab_availability`); they are not part of this permission union, which composes object / field / Apex / system / custom permissions AND record-type visibilities (for the per-object grouped record-type view use `recordtype_availability`).",
35223
36218
  "Field-level access is summarised here (count of fields with FLS); use `field_access_audit` for a specific field. Object permission is NOT record access \u2014 record visibility still depends on OWD + sharing (`why_cant_user_see_record`)."
35224
36219
  ]);
35225
36220
  effectivePermissionsHandler = async (ctx, input2) => {
@@ -35260,6 +36255,8 @@ var init_effective_permissions = __esm({
35260
36255
  const apexClasses = /* @__PURE__ */ new Set();
35261
36256
  const systemPermMap = /* @__PURE__ */ new Map();
35262
36257
  const customPermMap = /* @__PURE__ */ new Map();
36258
+ const rtVisMap = /* @__PURE__ */ new Map();
36259
+ const containersWithoutRtData = [];
35263
36260
  const presentContainers = [];
35264
36261
  const missingContainers = [];
35265
36262
  for (const containerId of containers) {
@@ -35282,6 +36279,24 @@ var init_effective_permissions = __esm({
35282
36279
  systemPermMap.set(p2, set);
35283
36280
  }
35284
36281
  }
36282
+ const rtRaw = nodeResult.value.properties["recordTypeVisibilities"];
36283
+ if (Array.isArray(rtRaw)) {
36284
+ for (const entry of rtRaw) {
36285
+ if (entry === null || typeof entry !== "object")
36286
+ continue;
36287
+ const rt2 = entry.recordType;
36288
+ if (typeof rt2 !== "string")
36289
+ continue;
36290
+ const accum = rtVisMap.get(rt2) ?? { visible: false, grantedBy: /* @__PURE__ */ new Set() };
36291
+ if (entry.visible !== false) {
36292
+ accum.visible = true;
36293
+ accum.grantedBy.add(containerId);
36294
+ }
36295
+ rtVisMap.set(rt2, accum);
36296
+ }
36297
+ } else {
36298
+ containersWithoutRtData.push(containerId);
36299
+ }
35285
36300
  const edgesResult = await listEdges(ctx.graph, containerId, {
35286
36301
  direction: "out",
35287
36302
  edgeType: "grantedBy"
@@ -35352,6 +36367,11 @@ var init_effective_permissions = __esm({
35352
36367
  });
35353
36368
  }
35354
36369
  const missingCustomPerms = customPermissions.filter((c2) => c2.targetMissing).length;
36370
+ const recordTypeVisibilities = [...rtVisMap.entries()].map(([recordType, a2]) => ({
36371
+ recordType,
36372
+ visible: a2.visible,
36373
+ grantedBy: [...a2.grantedBy].sort()
36374
+ })).sort((x2, y2) => x2.recordType < y2.recordType ? -1 : x2.recordType > y2.recordType ? 1 : 0);
35355
36375
  const totalObjects = objectPermissions.length;
35356
36376
  const limit = input2.limit ?? DEFAULT_LIMIT5;
35357
36377
  const TOOL = "sfi.effective_permissions";
@@ -35405,18 +36425,23 @@ var init_effective_permissions = __esm({
35405
36425
  if (missingCustomPerms > 0) {
35406
36426
  disclosures.push(`${missingCustomPerms} granted custom permission(s) name a definition not present in this vault (targetMissing) \u2014 likely managed-package or not retrieved; the grant is declared but the definition is not resolvable here. Custom permissions are NOT system userPermissions, so they are not double-counted under systemPermissions.`);
35407
36427
  }
36428
+ if (containersWithoutRtData.length > 0) {
36429
+ disclosures.push(`${containersWithoutRtData.length} container(s) carry no extracted \`recordTypeVisibilities\` property (${[...containersWithoutRtData].sort().join(", ")}) \u2014 the vault was refreshed before record-type extraction, so their record-type visibility is NOT in this union; re-run \`/sfi-refresh\`. The missing contribution is "not modeled", never a verified "no record types".`);
36430
+ }
35408
36431
  return ok({
35409
36432
  data: {
35410
36433
  containers: presentContainers,
35411
36434
  objectPermissions: objectPage,
35412
36435
  systemPermissions: systemPage,
35413
36436
  customPermissions,
36437
+ recordTypeVisibilities,
35414
36438
  summary: {
35415
36439
  objects: totalObjects,
35416
36440
  fieldsWithFls: fieldsWithFls.size,
35417
36441
  apexClasses: apexClasses.size,
35418
36442
  systemPermissions: systemPermissions.length,
35419
- customPermissions: customPermissions.length
36443
+ customPermissions: customPermissions.length,
36444
+ recordTypeVisibilities: recordTypeVisibilities.length
35420
36445
  },
35421
36446
  limit,
35422
36447
  offset,
@@ -40698,8 +41723,9 @@ var init_field_lineage = __esm({
40698
41723
  });
40699
41724
 
40700
41725
  // ../mcp/dist/src/tools/field-mapping-between-objects.js
41726
+ import { basename as basename12 } from "node:path";
40701
41727
  import { z as z51 } from "zod";
40702
- var fieldMappingBetweenObjectsInputSchema, parsePropertiesJson5, loadFields2, tokenize4, jaccard, fieldTokens, TEXT_TYPES, NUMBER_TYPES, DATE_TYPES, PICKLIST_TYPES2, REFERENCE_TYPES, typeCompatible, openVault, HEURISTIC_MAPPING_DISCLOSURE, vaultNotFoundResponse2, fieldMappingBetweenObjectsHandler;
41728
+ var fieldMappingBetweenObjectsInputSchema, parsePropertiesJson5, loadFields2, tokenize4, jaccard, fieldTokens, TEXT_TYPES, NUMBER_TYPES, DATE_TYPES, PICKLIST_TYPES2, REFERENCE_TYPES, typeCompatible, openVault, HEURISTIC_MAPPING_DISCLOSURE, servedVaultRef, isSelfReferentialAlias, SINGLE_VAULT_NOTE, OMIT_VAULT_HINT, vaultNotFoundResponse2, fieldMappingBetweenObjectsHandler;
40703
41729
  var init_field_mapping_between_objects = __esm({
40704
41730
  "../mcp/dist/src/tools/field-mapping-between-objects.js"() {
40705
41731
  "use strict";
@@ -40707,7 +41733,12 @@ var init_field_mapping_between_objects = __esm({
40707
41733
  init_src2();
40708
41734
  init_src3();
40709
41735
  fieldMappingBetweenObjectsInputSchema = z51.object({
40710
- vault: z51.string().min(1),
41736
+ /**
41737
+ * Optional registered-vault alias. When omitted the tool answers from
41738
+ * the SERVED vault (the one this MCP session was launched against) —
41739
+ * the normal single-vault deployment needs no registry at all.
41740
+ */
41741
+ vault: z51.string().min(1).optional(),
40711
41742
  objectA: z51.string().min(1),
40712
41743
  objectB: z51.string().min(1),
40713
41744
  similarityThreshold: z51.number().min(0).max(1).optional(),
@@ -40815,8 +41846,22 @@ var init_field_mapping_between_objects = __esm({
40815
41846
  return ok({ store, dispose: async () => closeGraph(store) });
40816
41847
  };
40817
41848
  HEURISTIC_MAPPING_DISCLOSURE = "field-mapping suggestions are heuristic \u2014 labels are matched by token overlap and types by compatibility table. Verify each suggested pair against your business rules before relying on the mapping for a migration script.";
40818
- vaultNotFoundResponse2 = (vault, objectA, objectB, ctx) => {
40819
- const message = `vault alias '${vault}' is not registered. Run \`sfi register-vault ${vault} <path>\` first, or \`sfi list-vaults\` to see what's registered.`;
41849
+ servedVaultRef = (ctx) => ({
41850
+ alias: basename12(ctx.vaultRoot),
41851
+ path: ctx.vaultRoot,
41852
+ registeredAt: "",
41853
+ lastRefreshedAt: ctx.manifest.refreshedAt,
41854
+ sourceTreeHash: ctx.manifest.sourceTreeHash,
41855
+ componentCount: null
41856
+ });
41857
+ isSelfReferentialAlias = (alias, vaultRoot) => {
41858
+ const a2 = alias.toLowerCase();
41859
+ return a2 === vaultRoot.toLowerCase() || a2 === basename12(vaultRoot).toLowerCase();
41860
+ };
41861
+ SINGLE_VAULT_NOTE = "No multi-vault registry found (set SF_INTELLIGENCE_REGISTRY_PATH to enable). This looks like a single-vault install \u2014 answered from the served vault.";
41862
+ OMIT_VAULT_HINT = "Omit `vault` to map fields within the served vault.";
41863
+ vaultNotFoundResponse2 = (vault, objectA, objectB, ctx, extraBoundaries = []) => {
41864
+ const message = `vault alias '${vault}' is not registered. Run \`sfi register-vault ${vault} <path>\` first, or \`sfi list-vaults\` to see what's registered. ${OMIT_VAULT_HINT}`;
40820
41865
  return ok({
40821
41866
  data: {
40822
41867
  vault: {
@@ -40832,7 +41877,7 @@ var init_field_mapping_between_objects = __esm({
40832
41877
  suggestedPairs: [],
40833
41878
  unpairedFromA: [],
40834
41879
  unpairedFromB: [],
40835
- boundaries: [HEURISTIC_MAPPING_DISCLOSURE, message]
41880
+ boundaries: [HEURISTIC_MAPPING_DISCLOSURE, message, ...extraBoundaries]
40836
41881
  },
40837
41882
  vaultState: {
40838
41883
  sourceTreeHash: ctx.manifest.sourceTreeHash,
@@ -40844,22 +41889,40 @@ var init_field_mapping_between_objects = __esm({
40844
41889
  const registryRoot = findRegistryRoot(ctx.vaultRoot);
40845
41890
  const threshold = input2.similarityThreshold ?? 0.5;
40846
41891
  const includeTypeIncompatible = input2.includeTypeIncompatible === true;
40847
- const registry = await loadRegistry(registryRoot);
40848
- if (!registry.ok && registry.error.kind === "registry-missing") {
40849
- return vaultNotFoundResponse2(input2.vault, input2.objectA, input2.objectB, ctx);
40850
- }
40851
- const pathResult = await resolveVault(registryRoot, input2.vault);
40852
- if (!pathResult.ok) {
40853
- return vaultNotFoundResponse2(input2.vault, input2.objectA, input2.objectB, ctx);
40854
- }
40855
- const vaultRefResult = await getVaultRef(registryRoot, input2.vault);
40856
- if (!vaultRefResult.ok) {
40857
- return err({
40858
- kind: "internal",
40859
- message: "failed to load vault metadata after alias resolution"
40860
- });
41892
+ let vaultPath;
41893
+ let vaultRef;
41894
+ const extraBoundaries = [];
41895
+ if (input2.vault === void 0) {
41896
+ vaultPath = ctx.vaultRoot;
41897
+ vaultRef = servedVaultRef(ctx);
41898
+ } else {
41899
+ const registry = await loadRegistry(registryRoot);
41900
+ if (!registry.ok && registry.error.kind === "registry-missing") {
41901
+ if (!isSelfReferentialAlias(input2.vault, ctx.vaultRoot)) {
41902
+ return vaultNotFoundResponse2(input2.vault, input2.objectA, input2.objectB, ctx, [
41903
+ "No multi-vault registry found (set SF_INTELLIGENCE_REGISTRY_PATH to enable). This looks like a single-vault install \u2014 omit `vault` to map fields within the served vault."
41904
+ ]);
41905
+ }
41906
+ vaultPath = ctx.vaultRoot;
41907
+ vaultRef = { ...servedVaultRef(ctx), alias: input2.vault };
41908
+ extraBoundaries.push(SINGLE_VAULT_NOTE);
41909
+ } else {
41910
+ const pathResult = await resolveVault(registryRoot, input2.vault);
41911
+ if (!pathResult.ok) {
41912
+ return vaultNotFoundResponse2(input2.vault, input2.objectA, input2.objectB, ctx);
41913
+ }
41914
+ const vaultRefResult = await getVaultRef(registryRoot, input2.vault);
41915
+ if (!vaultRefResult.ok) {
41916
+ return err({
41917
+ kind: "internal",
41918
+ message: "failed to load vault metadata after alias resolution"
41919
+ });
41920
+ }
41921
+ vaultPath = pathResult.value;
41922
+ vaultRef = vaultRefResult.value;
41923
+ }
40861
41924
  }
40862
- const opened = await openVault(ctx, pathResult.value);
41925
+ const opened = await openVault(ctx, vaultPath);
40863
41926
  if (!opened.ok)
40864
41927
  return opened;
40865
41928
  try {
@@ -40901,13 +41964,13 @@ var init_field_mapping_between_objects = __esm({
40901
41964
  const unpairedFromB = fieldsB.filter((f2) => !claimedB.has(f2.fieldApiName)).map((f2) => f2.fieldApiName).sort();
40902
41965
  return ok({
40903
41966
  data: {
40904
- vault: vaultRefResult.value,
41967
+ vault: vaultRef,
40905
41968
  objectA: { apiName: input2.objectA, fieldCount: fieldsA.length },
40906
41969
  objectB: { apiName: input2.objectB, fieldCount: fieldsB.length },
40907
41970
  suggestedPairs,
40908
41971
  unpairedFromA,
40909
41972
  unpairedFromB,
40910
- boundaries: [HEURISTIC_MAPPING_DISCLOSURE]
41973
+ boundaries: [HEURISTIC_MAPPING_DISCLOSURE, ...extraBoundaries]
40911
41974
  },
40912
41975
  vaultState: {
40913
41976
  sourceTreeHash: ctx.manifest.sourceTreeHash,
@@ -42275,7 +43338,9 @@ var init_find_component_usages = __esm({
42275
43338
  if (!edgesRes.ok) {
42276
43339
  return err({ kind: "internal", message: `graph query failed: ${edgesRes.error.message}` });
42277
43340
  }
42278
- const usageEdges = edgesRes.value.filter((e2) => !NON_USAGE_EDGE_TYPES.has(e2.edgeType));
43341
+ const incomingEdges = edgesRes.value;
43342
+ const usageEdges = incomingEdges.filter((e2) => !NON_USAGE_EDGE_TYPES.has(e2.edgeType));
43343
+ const grantEdges = incomingEdges.filter((e2) => e2.edgeType === "grantedBy");
42279
43344
  const byType = /* @__PURE__ */ new Map();
42280
43345
  for (const e2 of usageEdges) {
42281
43346
  const rt2 = typeOf(e2.fromId);
@@ -42322,7 +43387,16 @@ var init_find_component_usages = __esm({
42322
43387
  }
42323
43388
  }
42324
43389
  const hasStaticEvidence = graphReferrerCount > 0 || grepMatches.length > 0;
42325
- if (!retrieved && graphReferrerCount === 0 && !hasStaticEvidence) {
43390
+ const surfaceGrants = targetType === "CustomPermission" || usageEdges.length === 0 && grantEdges.length > 0;
43391
+ let grantedBySection;
43392
+ if (surfaceGrants) {
43393
+ const granterIds = [...new Set(grantEdges.map((e2) => e2.fromId))].sort();
43394
+ grantedBySection = {
43395
+ count: granterIds.length,
43396
+ granters: granterIds.slice(0, GRAPH_REFERRER_SAMPLE).map((id) => ({ id, type: typeOf(id) }))
43397
+ };
43398
+ }
43399
+ if (!retrieved && graphReferrerCount === 0 && !hasStaticEvidence && grantEdges.length === 0) {
42326
43400
  return err({
42327
43401
  kind: "component-not-found",
42328
43402
  message: `no component or referrer matches \`${componentId}\` in this vault`,
@@ -42345,6 +43419,9 @@ var init_find_component_usages = __esm({
42345
43419
  if (GREP_RELIANT_PREFIXES.has(targetType)) {
42346
43420
  boundaries.push(`${targetType} usage has a weaker graph tier \u2014 FRONTEND references ($Label / $Resource / $Setup and @salesforce imports in LWC/Aura/Visualforce) are modeled as graph edges on vaults refreshed at 0.1.10+, but Apex references (System.Label.X, dynamic config reads) are still grep-only, so the grep supplement carries part of the answer here. Confirm by reading the matched source.`);
42347
43421
  }
43422
+ if (grantedBySection !== void 0) {
43423
+ boundaries.push(`Access grants are listed SEPARATELY in \`grantedBy\` (${grantedBySection.count} granting container(s)) \u2014 a grant is ACCESS, not usage, so granters never appear in graphReferrers. For a CustomPermission this answers "which Profiles / PermissionSets grant it?"; checking a custom permission in code (FeatureManagement / $Permission) is usage and stays in the usage tiers.`);
43424
+ }
42348
43425
  if (!retrieved) {
42349
43426
  boundaries.push("This component is a PHANTOM \u2014 referenced by the edges below but NOT retrieved into the vault, so its own definition is unavailable; the referrer list is still valid.");
42350
43427
  }
@@ -42352,6 +43429,7 @@ var init_find_component_usages = __esm({
42352
43429
  data: {
42353
43430
  target: { componentId, type: targetType, apiName: targetApiName, retrieved },
42354
43431
  graphReferrers,
43432
+ ...grantedBySection !== void 0 ? { grantedBy: grantedBySection } : {},
42355
43433
  grepSupplement: {
42356
43434
  tier: "text-match",
42357
43435
  ran: grepRan,
@@ -43306,7 +44384,7 @@ var init_find_formula_references = __esm({
43306
44384
 
43307
44385
  // ../mcp/dist/src/tools/find-hardcoded-values-anywhere.js
43308
44386
  import { readFile as readFile74 } from "node:fs/promises";
43309
- import { basename as basename12 } from "node:path";
44387
+ import { basename as basename13 } from "node:path";
43310
44388
  import { z as z63 } from "zod";
43311
44389
  var MAX_LIMIT9, DEFAULT_LIMIT9, FIND_HARDCODED_ANYWHERE_TOOL, SALESFORCE_ID_REGEX, EMAIL_REGEX, DATE_REGEX, NUMERIC_REGEX, HARDCODED_APEX_RULES, APEX_SOURCE_SUFFIXES, scanApexSourceForEmails, NUMERIC_FP_DISCLOSURE, ID_FP_DISCLOSURE, TEST_CLASS_REFUSAL_DISCLOSURE, APEX_SOURCE_EMAIL_SCAN_DISCLOSURE, findHardcodedValuesAnywhereInputBaseSchema, findHardcodedValuesAnywhereInputSchema, RULE_TO_CATEGORY, coerceIssue4, regexForCategory, snippetAround, scanText, isTestClass, compareMatches, matchKey, findHardcodedValuesAnywhereHandler;
43312
44390
  var init_find_hardcoded_values_anywhere = __esm({
@@ -43349,7 +44427,7 @@ var init_find_hardcoded_values_anywhere = __esm({
43349
44427
  } catch {
43350
44428
  continue;
43351
44429
  }
43352
- const name = basename12(file.absolutePath);
44430
+ const name = basename13(file.absolutePath);
43353
44431
  const apiName = name.endsWith(".trigger") ? name.slice(0, -".trigger".length) : name.slice(0, -".cls".length);
43354
44432
  const componentType = name.endsWith(".trigger") ? "ApexTrigger" : "ApexClass";
43355
44433
  const componentId = `${componentType}:${apiName}`;
@@ -50063,7 +51141,7 @@ var init_lightning_pages = __esm({
50063
51141
 
50064
51142
  // ../mcp/dist/src/tools/list-components.js
50065
51143
  import { z as z90 } from "zod";
50066
- var LIST_COMPONENTS_TOOL, STANDARD_OBJECT_API_NAMES2, objectApiNameFromParentId, isStandardObjectApiName, COMPONENT_TYPES2, LIST_MAX_LIMIT2, LIST_DEFAULT_LIMIT2, LIST_PAYLOAD_BUDGET_BYTES, fitNodesToBudget, APEX_BOOLEAN_FILTERS, coercedOptionalBoolean, listComponentsInputSchema, listComponentsHandler;
51144
+ var LIST_COMPONENTS_TOOL, STANDARD_OBJECT_API_NAMES2, objectApiNameFromParentId, isStandardObjectApiName, COMPONENT_TYPES2, LIST_MAX_LIMIT2, LIST_DEFAULT_LIMIT2, LIST_PAYLOAD_BUDGET_BYTES, ITEM_SLIM_THRESHOLD_BYTES, SLIM_STRING_MAX_CHARS, slimOversizedNode, fitNodesToBudget, APEX_BOOLEAN_FILTERS, coercedOptionalBoolean, listComponentsInputSchema, listComponentsHandler;
50067
51145
  var init_list_components = __esm({
50068
51146
  "../mcp/dist/src/tools/list-components.js"() {
50069
51147
  "use strict";
@@ -50172,6 +51250,21 @@ var init_list_components = __esm({
50172
51250
  LIST_MAX_LIMIT2 = 500;
50173
51251
  LIST_DEFAULT_LIMIT2 = 50;
50174
51252
  LIST_PAYLOAD_BUDGET_BYTES = 38e3;
51253
+ ITEM_SLIM_THRESHOLD_BYTES = 2048;
51254
+ SLIM_STRING_MAX_CHARS = 256;
51255
+ slimOversizedNode = (node) => {
51256
+ if (Buffer.byteLength(JSON.stringify(node), "utf8") <= ITEM_SLIM_THRESHOLD_BYTES) {
51257
+ return node;
51258
+ }
51259
+ const compact = {};
51260
+ for (const [key, value] of Object.entries(node.properties ?? {})) {
51261
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string" && value.length <= SLIM_STRING_MAX_CHARS) {
51262
+ compact[key] = value;
51263
+ }
51264
+ }
51265
+ compact["propertiesTruncated"] = true;
51266
+ return { ...node, properties: compact };
51267
+ };
50175
51268
  fitNodesToBudget = (nodes, budgetBytes) => {
50176
51269
  const kept = [];
50177
51270
  let used = 0;
@@ -50299,7 +51392,9 @@ var init_list_components = __esm({
50299
51392
  }
50300
51393
  }
50301
51394
  }
50302
- const { kept, trimmed } = fitNodesToBudget(pageNodes, LIST_PAYLOAD_BUDGET_BYTES);
51395
+ const slimmedPageNodes = pageNodes.map(slimOversizedNode);
51396
+ const { kept, trimmed } = fitNodesToBudget(slimmedPageNodes, LIST_PAYLOAD_BUDGET_BYTES);
51397
+ const propertiesSlimmed = kept.some((n2) => n2.properties?.["propertiesTruncated"] === true);
50303
51398
  const hasMore = pageNodes.length === limit || trimmed;
50304
51399
  let retrievalHint;
50305
51400
  if (offset === 0 && pageNodes.length === 0 && !hasPropertyFilter && !hasStringPropertyFilter && !recordTriggered) {
@@ -50364,6 +51459,7 @@ var init_list_components = __esm({
50364
51459
  ...docFallbackNote !== void 0 ? { docFallbackNote } : {},
50365
51460
  ...coverageCaveat !== void 0 ? { coverageCaveat } : {},
50366
51461
  ...formulaFieldCount !== void 0 ? { formulaFieldCount } : {},
51462
+ ...propertiesSlimmed ? { propertiesSlimmed: true } : {},
50367
51463
  ...trimmed ? {
50368
51464
  truncated: true,
50369
51465
  note: `Response trimmed to ${kept.length} of ${pageNodes.length} fetched rows to stay under the ~45 KB MCP response limit. Use totalCount (${totalCount}) for the authoritative vault count; advance with offset += ${kept.length} (or narrow via parentId) for the rest.`
@@ -54410,7 +55506,7 @@ import { createHash as createHash7 } from "node:crypto";
54410
55506
  import { readFile as readFile82 } from "node:fs/promises";
54411
55507
  import { join as join23, resolve as resolve3 } from "node:path";
54412
55508
  import { z as z115 } from "zod";
54413
- var RESOLVE_FALLBACK_MAX_TOKENS, tryResolveFallback, routeQuestionInputSchema, routeTrust, clarificationIdFor, selectedEntityArgsForRoute, extractEntityQuery, inferEntityTypes, refineEntityResolution, applyGlossaryAliases, entityAmbiguityRequiresClarification, splitCompoundQuestion, mixedInventoryAndStoragePlan, PLAN_FAMILY, ASSESSMENT_FAMILY, ROUTE_PREAMBLE_TOOLS, REGEX_BONUS, resolveActiveVaultAlias, buildRouteToolArgsMap, mergeRouteHintsIntoCandidates, invokeFromArgsMap, rerankForMode, MARGIN, DESTRUCTIVE_TOOL, INFORMATIONAL_IMPACT_TOOL, planesDiverge, risksDiverge, marginClarification, LIVE_DISCLOSURE_LOOKAHEAD, liveConsentDisclosure, guidanceForMode, buildFunnelCandidates, routerMode, routeQuestionHandler;
55509
+ var RESOLVE_FALLBACK_MAX_TOKENS, SAVE_ORDER_INTENTS, SINGLE_ENTITY_INTENTS, COMPARISON_ASIDE, stripComparisonAside, tryResolveFallback, routeQuestionInputSchema, routeTrust, clarificationIdFor, selectedEntityArgsForRoute, extractEntityQuery, inferEntityTypes, refineEntityResolution, applyGlossaryAliases, entityAmbiguityRequiresClarification, splitCompoundQuestion, mixedInventoryAndStoragePlan, PLAN_FAMILY, ASSESSMENT_FAMILY, ROUTE_PREAMBLE_TOOLS, TOOL_COMPATIBLE_TYPES, ACCESS_FLAVORED_TOOLS, resolvedTypeForGuard, applyComponentTypeGuard, REGEX_BONUS, resolveActiveVaultAlias, buildRouteToolArgsMap, mergeRouteHintsIntoCandidates, invokeFromArgsMap, rerankForMode, MARGIN, DESTRUCTIVE_TOOL, INFORMATIONAL_IMPACT_TOOL, planesDiverge, risksDiverge, marginClarification, LIVE_DISCLOSURE_LOOKAHEAD, liveConsentDisclosure, guidanceForMode, buildFunnelCandidates, routerMode, routeQuestionHandler;
54414
55510
  var init_route_question = __esm({
54415
55511
  "../mcp/dist/src/tools/route-question.js"() {
54416
55512
  "use strict";
@@ -54423,6 +55519,24 @@ var init_route_question = __esm({
54423
55519
  init_resolve2();
54424
55520
  init_tool_profile();
54425
55521
  RESOLVE_FALLBACK_MAX_TOKENS = 3;
55522
+ SAVE_ORDER_INTENTS = /* @__PURE__ */ new Set([
55523
+ "trigger-order",
55524
+ "save-behavior",
55525
+ "automation-on-object",
55526
+ "dlrs-recursion"
55527
+ ]);
55528
+ SINGLE_ENTITY_INTENTS = /* @__PURE__ */ new Set([
55529
+ "explain-flow",
55530
+ "explain-apex",
55531
+ "get-component"
55532
+ ]);
55533
+ COMPARISON_ASIDE = new RegExp("\\s*(?:,|\u2014|-|;)?\\s*\\b(?:is\\s+(?:it|this|that)\\s+the\\s+same\\s+as|is\\s+(?:it|this|that)\\s+different\\s+(?:from|to)|(?:the\\s+)?same\\s+as|compared\\s+to|or\\s+is\\s+(?:it|this|that)|vs\\.?|versus)\\b.*$", "i");
55534
+ stripComparisonAside = (question, intent) => {
55535
+ if (!SINGLE_ENTITY_INTENTS.has(intent))
55536
+ return question;
55537
+ const stripped = question.replace(COMPARISON_ASIDE, "").trim();
55538
+ return stripped.length > 0 ? stripped : question;
55539
+ };
54426
55540
  tryResolveFallback = async (ctx, question) => {
54427
55541
  const tokens = question.trim().split(/\s+/).filter((t2) => t2.length > 0);
54428
55542
  if (tokens.length === 0 || tokens.length > RESOLVE_FALLBACK_MAX_TOKENS) {
@@ -54536,23 +55650,29 @@ var init_route_question = __esm({
54536
55650
  if (intent === "what-if-method-signature" && apiReference.includes(".")) {
54537
55651
  return `${apiReference.slice(0, apiReference.indexOf("."))} class`;
54538
55652
  }
55653
+ if (!apiReference.includes(".")) {
55654
+ const qualifier = question.match(new RegExp(`\\b${apiReference}\\b\\s+(?:field\\s+)?on\\s+(?:the\\s+)?([A-Za-z][A-Za-z0-9_]*)\\b`, "i"))?.[1];
55655
+ if (qualifier !== void 0)
55656
+ return `${qualifier}.${apiReference}`;
55657
+ }
54539
55658
  return apiReference;
54540
55659
  }
54541
55660
  const prefixedTypePhrase = question.match(/\b((?:validation\s+rule|permission\s+set|record\s+type|page\s+layout|object|field|flow|class|trigger|layout|profile|report|dashboard)\s+(?:named\s+)?[A-Z][A-Za-z0-9_]*(?:\s+[A-Z][A-Za-z0-9_]*){0,5})\b/)?.[1];
54542
55661
  if (prefixedTypePhrase !== void 0)
54543
55662
  return prefixedTypePhrase.trim();
54544
- const typedMatch = question.match(/\b(?:the\s+)?([A-Za-z][A-Za-z0-9_]*(?:[\s_-]+[A-Za-z][A-Za-z0-9_]*){0,5}\s+(?:object|field|flow|class|trigger|layout|profile|permission\s+set|record\s+type|validation\s+rule|report|dashboard))(?:\s+(?:on|for|of)\s+(?:the\s+)?([A-Za-z][A-Za-z0-9_]*)(?:\s+object)?)?\b/i);
55663
+ const typedMatch = question.match(/\b(?:the\s+)?([A-Za-z][A-Za-z0-9_]*(?:[\s_-]+[A-Za-z][A-Za-z0-9_]*){0,5}\s+(?:object|field|flow|class|trigger|layout|profile|permission\s+set|record\s+type|validation\s+rule|report|dashboard|logic))(?:\s+(?:on|for|of)\s+(?:the\s+)?([A-Za-z][A-Za-z0-9_]*)(?:\s+object)?)?\b/i);
54545
55664
  const typedPhrase = typedMatch?.[1];
54546
55665
  if (typedPhrase === void 0)
54547
55666
  return null;
54548
- const cleaned = typedPhrase.replace(/^(?:(?:what|which|who|where|when|why|how|is|are|can|does|do|did|show|find|explain|locate|list|references?|owns?|edit|read|view|access|change|delete|remove|possible|values?|api|version|data|type|for|of|the|this|that|in|on|to|used|assigned|set)\s+)+/i, "").trim();
54549
- if (/\b(?:and|when|should|use|there|any|that|no|available|tools?|required\s+fields?|record\s+types?)\b/i.test(cleaned))
55667
+ const cleaned = typedPhrase.replace(/^(?:(?:what|whatever|which|who|where|when|why|how|is|are|can|does|do|did|show|find|explain|locate|list|calls?|invokes?|references?|owns?|edit|read|view|access|change|delete|remove|possible|values?|apex|api|version|data|type|for|of|the|this|that|in|on|to|used|assigned|set|every|all|each|any|me|a|an)\s+)+/i, "").trim();
55668
+ if (/\b(?:and|when|should|use|there|any|that|no|available|tools?|difference|between|required\s+fields?|record\s+types?)\b/i.test(cleaned))
54550
55669
  return null;
54551
- const distinctive = cleaned.replace(/\b(?:object|field|flow|class|trigger|page|layout|profile|permission|set|record|type|validation|rule|report|dashboard|data)\b/gi, "").replace(/[^A-Za-z0-9_]+/g, "");
55670
+ const distinctive = cleaned.replace(/\b(?:object|field|flow|class|trigger|page|layout|profile|permission|set|record|type|validation|rule|report|dashboard|data|logic)\b/gi, "").replace(/[^A-Za-z0-9_]+/g, "");
54552
55671
  if (distinctive.length === 0)
54553
55672
  return null;
55673
+ const phrase = cleaned.replace(/\s+logic$/i, "");
54554
55674
  const parent = typedMatch?.[2];
54555
- return parent === void 0 ? cleaned : `${cleaned} on ${parent}`;
55675
+ return parent === void 0 ? phrase : `${phrase} on ${parent}`;
54556
55676
  };
54557
55677
  inferEntityTypes = (query, intent, question) => {
54558
55678
  if (intent === "what-if-method-signature")
@@ -54692,6 +55812,54 @@ var init_route_question = __esm({
54692
55812
  PLAN_FAMILY = /^sfi\.(what_if_|get_impact|safe_to_delete|downstream_effects|tests_for_change|field_lineage)/;
54693
55813
  ASSESSMENT_FAMILY = /(_risk_report$|^sfi\.release_readiness|^sfi\.promotion_readiness|^sfi\.coverage_report|^sfi\.tech_debt_score|^sfi\.governor_limit_risks|^sfi\.crud_fls_audit)/;
54694
55814
  ROUTE_PREAMBLE_TOOLS = /* @__PURE__ */ new Set(["sfi.resolve", "sfi.capabilities"]);
55815
+ TOOL_COMPATIBLE_TYPES = /* @__PURE__ */ new Map([
55816
+ ["sfi.call_graph", /* @__PURE__ */ new Set(["ApexClass", "ApexTrigger"])],
55817
+ ["sfi.method_reachability", /* @__PURE__ */ new Set(["ApexClass", "ApexTrigger"])],
55818
+ ["sfi.explain_apex_method", /* @__PURE__ */ new Set(["ApexClass", "ApexTrigger"])],
55819
+ ["sfi.who_can_access_object", /* @__PURE__ */ new Set(["CustomObject"])],
55820
+ ["sfi.object_access_audit", /* @__PURE__ */ new Set(["CustomObject"])],
55821
+ ["sfi.field_access_audit", /* @__PURE__ */ new Set(["CustomField"])]
55822
+ ]);
55823
+ ACCESS_FLAVORED_TOOLS = /* @__PURE__ */ new Set([
55824
+ "sfi.who_can_access_object",
55825
+ "sfi.object_access_audit",
55826
+ "sfi.field_access_audit"
55827
+ ]);
55828
+ resolvedTypeForGuard = (route, resolution) => {
55829
+ if (resolution === null || resolution.candidates.length === 0)
55830
+ return null;
55831
+ const top = resolution.candidates[0];
55832
+ if (resolution.disposition === "exact")
55833
+ return top.type;
55834
+ if (resolution.disposition !== "ambiguous")
55835
+ return null;
55836
+ const guardedCompatibleTypes = new Set(route.tools.flatMap((tool) => [...TOOL_COMPATIBLE_TYPES.get(tool) ?? []]));
55837
+ if (guardedCompatibleTypes.size === 0)
55838
+ return null;
55839
+ return resolution.candidates.some((candidate) => guardedCompatibleTypes.has(candidate.type)) ? null : top.type;
55840
+ };
55841
+ applyComponentTypeGuard = (route, resolvedType) => {
55842
+ if (resolvedType !== "Flow")
55843
+ return route;
55844
+ const incompatible = new Set(route.tools.filter((tool) => {
55845
+ const compatible = TOOL_COMPATIBLE_TYPES.get(tool);
55846
+ return compatible !== void 0 && !compatible.has(resolvedType);
55847
+ }));
55848
+ if (incompatible.size === 0)
55849
+ return route;
55850
+ const substitutes = [...incompatible].some((tool) => ACCESS_FLAVORED_TOOLS.has(tool)) ? ["sfi.who_can_run", "sfi.explain_flow", "sfi.get_impact"] : ["sfi.explain_flow", "sfi.get_impact"];
55851
+ const substituteSet = new Set(substitutes);
55852
+ const preamble = route.tools.filter((tool) => ROUTE_PREAMBLE_TOOLS.has(tool));
55853
+ const kept = route.tools.filter((tool) => !ROUTE_PREAMBLE_TOOLS.has(tool) && !incompatible.has(tool) && !substituteSet.has(tool));
55854
+ const originalTools = route.tools;
55855
+ const tools = [...preamble, ...substitutes, ...kept];
55856
+ return {
55857
+ ...route,
55858
+ tools,
55859
+ reason: `${route.reason} The named entity resolved to a Flow, so tools that require an Apex class, object, or field id were replaced with the Flow-appropriate ones.`,
55860
+ plan: route.plan.map((step3) => step3.tools === originalTools ? { ...step3, tools } : step3)
55861
+ };
55862
+ };
54695
55863
  REGEX_BONUS = 0.25;
54696
55864
  resolveActiveVaultAlias = async (ctx) => {
54697
55865
  const normalizedRoot = resolve3(ctx.vaultRoot);
@@ -54902,8 +56070,11 @@ var init_route_question = __esm({
54902
56070
  suggestedArgs: { type: "CustomObject" }
54903
56071
  };
54904
56072
  }
54905
- const entityQuery = route.needsResolve ? extractEntityQuery(input2.question, route.intent) : null;
54906
- const entityTypes = entityQuery === null ? [] : inferEntityTypes(entityQuery, route.intent, input2.question);
56073
+ const saveOrderIntent = SAVE_ORDER_INTENTS.has(route.intent);
56074
+ const suggestedObject = route.suggestedArgs?.["objectApiName"];
56075
+ const entityExtractionSource = stripComparisonAside(input2.question, route.intent);
56076
+ const entityQuery = !route.needsResolve ? null : saveOrderIntent ? typeof suggestedObject === "string" ? suggestedObject : null : extractEntityQuery(entityExtractionSource, route.intent);
56077
+ const entityTypes = entityQuery === null ? [] : saveOrderIntent ? ["CustomObject"] : inferEntityTypes(entityQuery, route.intent, input2.question);
54907
56078
  const entityResolution = entityQuery !== null ? await resolveComponents(ctx.graph, entityQuery, {
54908
56079
  limit: 5,
54909
56080
  ...entityTypes.length > 0 ? { types: entityTypes } : {}
@@ -54942,6 +56113,7 @@ var init_route_question = __esm({
54942
56113
  } else if (entityEvidence?.disposition === "ambiguous" && route.clarification === null) {
54943
56114
  route = { ...route, confidence: "medium" };
54944
56115
  }
56116
+ route = applyComponentTypeGuard(route, resolvedTypeForGuard(route, refinedEntityResolution));
54945
56117
  const marginRouteToolArgs = await buildRouteToolArgsMap(route, ctx);
54946
56118
  if (routerMode() === "hybrid" && route.confidence !== "high") {
54947
56119
  const gateCandidates = buildFunnelCandidates(route, input2.question, marginRouteToolArgs, input2.mode);
@@ -54950,6 +56122,59 @@ var init_route_question = __esm({
54950
56122
  route = { ...route, confidence: "low", clarification: marginClar };
54951
56123
  }
54952
56124
  }
56125
+ if (entityQuery !== null && refinedEntityResolution !== null && refinedEntityResolution.disposition === "none") {
56126
+ const bareQuery = entityQuery.replace(/^(?:the|a|an)\s+/i, "").replace(/\s+(?:class(?:es)?|trigger(?:s)?|field(?:s)?|object(?:s)?|flow(?:s)?|component(?:s)?|layout(?:s)?|profile(?:s)?|report(?:s)?|dashboard(?:s)?|rule(?:s)?)$/i, "");
56127
+ const bareRetry = bareQuery !== entityQuery && bareQuery.length > 0 ? await resolveComponents(ctx.graph, bareQuery, {
56128
+ limit: 5,
56129
+ ...entityTypes.length > 0 ? { types: entityTypes } : {}
56130
+ }) : null;
56131
+ const bareResolution = bareRetry?.ok === true && bareRetry.value.disposition !== "none" ? bareRetry.value : null;
56132
+ if (bareResolution !== null) {
56133
+ entityEvidence = {
56134
+ query: bareQuery,
56135
+ typeHints: entityTypes,
56136
+ disposition: bareResolution.disposition,
56137
+ clarificationRequired: false,
56138
+ warning: bareResolution.disposition === "ambiguous" ? "Possible component matches were found, but none is strong enough to interrupt routing. Resolve the component before executing a component-specific analysis." : null,
56139
+ candidates: bareResolution.candidates.slice(0, 5).map((candidate) => ({
56140
+ componentId: candidate.id,
56141
+ type: candidate.type,
56142
+ apiName: candidate.apiName,
56143
+ label: candidate.label,
56144
+ parentApiName: candidate.parentApiName,
56145
+ score: candidate.score,
56146
+ base: candidate.base,
56147
+ matchKind: candidate.matchKind,
56148
+ evidence: candidate.evidence
56149
+ }))
56150
+ };
56151
+ } else {
56152
+ const premiseWarning = `PREMISE CHECK: no component matching '${entityQuery}' exists in the vault \u2014 verify the name (a typo, or metadata newer than the last refresh; /sfi-refresh may help). The routed tool fails closed on an unknown component; do not present its error as an answer about a real component.`;
56153
+ route = {
56154
+ ...route,
56155
+ confidence: "low",
56156
+ reason: `${route.reason} ${premiseWarning}`
56157
+ };
56158
+ entityEvidence = {
56159
+ query: entityQuery,
56160
+ typeHints: entityTypes,
56161
+ disposition: "none",
56162
+ clarificationRequired: false,
56163
+ warning: premiseWarning,
56164
+ candidates: []
56165
+ };
56166
+ }
56167
+ } else if (entityQuery !== null && refinedEntityResolution !== null && refinedEntityResolution.disposition === "ambiguous" && (/__(?:c|mdt|e|x|b|kav)\b/i.test(entityQuery) || entityQuery.includes(".")) && !refinedEntityResolution.candidates.some((candidate) => candidate.apiName.toLowerCase() === entityQuery.toLowerCase() || candidate.id.toLowerCase().endsWith(`:${entityQuery.toLowerCase()}`))) {
56168
+ const premiseWarning = `PREMISE CHECK: no component named '${entityQuery}' exists in the vault \u2014 the listed candidates are fuzzy lookalikes, not the component you named. Verify the name (a typo, or metadata newer than the last refresh; /sfi-refresh may help). The routed tool fails closed on an unknown component; do not present its error as an answer about a real component.`;
56169
+ route = {
56170
+ ...route,
56171
+ confidence: "low",
56172
+ reason: `${route.reason} ${premiseWarning}`
56173
+ };
56174
+ if (entityEvidence !== void 0) {
56175
+ entityEvidence = { ...entityEvidence, warning: premiseWarning };
56176
+ }
56177
+ }
54953
56178
  const clarificationId = clarificationIdFor(ctx.manifest.sourceTreeHash, route);
54954
56179
  if (clarificationId !== null && route.clarification !== null) {
54955
56180
  route = {
@@ -65549,7 +66774,7 @@ var init_tools = __esm({
65549
66774
  similarityThreshold: { type: "number", minimum: 0, maximum: 1 },
65550
66775
  includeTypeIncompatible: { type: "boolean" }
65551
66776
  },
65552
- required: ["vault", "objectA", "objectB"]
66777
+ required: ["objectA", "objectB"]
65553
66778
  });
65554
66779
  INTEGRATION_PROCEDURE_CHAIN_INPUT_SCHEMA = Object.freeze({
65555
66780
  type: "object",
@@ -65661,7 +66886,7 @@ var init_tools = __esm({
65661
66886
  },
65662
66887
  {
65663
66888
  name: "sfi.resolve",
65664
- description: "Typo-tolerant resolver: messy/misspelled text -> ranked candidate components with a disposition (exact|ambiguous|none) + per-candidate evidence. Call FIRST when the user names a component informally; tolerates typos, filler, and the org's own misspellings that search_components cannot. CONFIRMED glossary annotations act as curated synonyms (candidates marked `glossary-alias`) \u2014 an alias never shadows an exact api-name match, and a synonym shared by two components yields `ambiguous` + clarification. Heuristic; never silently picks.",
66889
+ description: "Typo-tolerant resolver: messy/misspelled text -> ranked candidate components with a disposition (exact|ambiguous|none) + per-candidate evidence. Call FIRST when the user names a component informally; tolerates typos, filler, and the org's own misspellings that search_components cannot. Leading/trailing schema nouns are TYPE hints, not name content: 'SSN field' scores exactly like bare 'SSN' (the noun is stripped from fuzzy matching and instead prefers candidates of the hinted type among equally-confident matches \u2014 it never floats a weak fuzzy match of that type over an exact-name match of another). A dotted 'Object.Field' query is a definitive parent-scoped hit. CONFIRMED glossary annotations act as curated synonyms (candidates marked `glossary-alias`) \u2014 an alias never shadows an exact api-name match, and a synonym shared by two components yields `ambiguous` + clarification. Heuristic; never silently picks.",
65665
66890
  inputSchema: RESOLVE_INPUT_SCHEMA
65666
66891
  },
65667
66892
  {
@@ -65691,7 +66916,7 @@ var init_tools = __esm({
65691
66916
  },
65692
66917
  {
65693
66918
  name: "sfi.route_question",
65694
- description: "Front-door router: for a plain-language question, surface a meaning-ranked shortlist of the sfi.* tools that can answer it \u2014 your host LLM picks which to run \u2014 plus the plane it belongs to (vault | live | hybrid | unknown), so the user never types a tool name. Read-only; it advises, it does not answer. Compound questions carry step ids and `dependsOn` edges: independent steps may run in parallel; a `then`-linked step waits for its prerequisite. On ambiguity it fails closed with `executionBlocked`, a clarification id, and offered options; resume deterministically by calling again with the exact same question plus `clarificationResponse: { clarificationId, selection }`. Stale ids and invented selections are rejected. Tells you when to sfi.resolve a named component first, whether the opt-in live plane is required, surfaces `suggestedArgs` (heuristic per-intent hints \u2014 e.g. `event: 'update'` for a save-order question so you can call `what_happens_on_save` without guessing the DML event), and \u2014 when the question hits a capability we lack \u2014 returns an honest 'unknown'/gap instead of fabricating (set `logGap: true` to also append the gap to the local backlog; off by default, privacy-first per CR-16); under `SFI_TOOL_PROFILE=core` the response also carries `invoke`: the routed tools as EXECUTABLE calls (core tools direct, everything else as the byte-identical `sfi.run_analysis` gateway envelope, suggestedArgs threaded) (a short phrase that merely NAMES a real vault component, with no question, is instead routed to sfi.resolve rather than 'unknown'). In the default hybrid mode the meaning-ranked `toolCandidates` are PRIMARY: every routable question carries the shortlist (offline TF-IDF over the capability map, no neural model, no network) plus a `guidance` line stating the loop YOU own \u2014 read the candidates \u2192 resolve any named component \u2192 pick/sequence the tool(s) \u2192 run them \u2192 ground via sfi.synthesize_answer. YOU decide which to run; the deterministic `route` rides along only as a non-authoritative HINT (suggested tool order + any resolved entity / suggestedArgs). Set `SFI_ROUTER_MODE=offline` for a deterministic, no-LLM route (Design A) where the route is authoritative and candidates are omitted \u2014 for CI / air-gapped hosts. An optional `mode` ('ask' | 'plan' | 'assessment') tailors the guidance and reranks the candidates toward that mode's family \u2014 'plan' favors the what_if_* / impact tools (an ordered change plan), 'assessment' favors the *_risk_report / readiness / coverage tools (a full evaluation), 'ask' is a quick grounded answer. Call this first on a vague/broad question to decide which tool(s) to run.",
66919
+ description: "Front-door router: for a plain-language question, surface a meaning-ranked shortlist of the sfi.* tools that can answer it \u2014 your host LLM picks which to run \u2014 plus the plane it belongs to (vault | live | hybrid | unknown), so the user never types a tool name. Read-only; it advises, it does not answer. Compound questions carry step ids and `dependsOn` edges: independent steps may run in parallel; a `then`-linked step waits for its prerequisite. On GENUINE ambiguity it fails closed with `executionBlocked`, a clarification id, and offered options; resume deterministically by calling again with the exact same question plus `clarificationResponse: { clarificationId, selection }`. Stale ids and invented selections are rejected. Bare schema nouns in the question (trigger/flow/field/object/profile/permission set/record type) are INTENT vocabulary, never named-entity lookups \u2014 'the Contact trigger' scopes a save-order question to the Contact OBJECT, it does not shop 'trigger' to the resolver; an object-qualified field ('Status__c on Case') is resolved parent-scoped; and an entity the resolver reports `exact` never raises the components-match menu \u2014 that menu is reserved for two genuinely competing components. The resolved TYPE also gates the routed tools: when the named entity resolves to a FLOW, tools that hard-error on a Flow id (call_graph / method_reachability / explain_apex_method, and the object/field access audits) are SWAPPED for the Flow-appropriate ones (who_can_run for access asks, explain_flow \u2014 which narrates the Apex the Flow invokes \u2014 plus get_impact) instead of routing into a guaranteed error. Qualifier words never outrank the HEAD question: 'bulk'/'load' on a save-order or test-coverage ask, 'seats'/'license' on a permission-set-assignment ask, and 'integration'/vendor words on a field-write / profile-security / what-if ask all stay on the head intent (never forced onto governor_limit_risks / live_license_usage / integration_map); field_lineage is reserved for explicit lineage/provenance questions ('where does this data come from'), never a field-adjacent default. FALSE PREMISE: when the question names a component the resolver cannot find \u2014 disposition `none`, or a literal API reference (dotted / __c-suffixed) that none of the fuzzy candidates actually match \u2014 the route is STILL returned (the routed tool fails closed on the unknown id) but its confidence is downgraded and `entityEvidence.warning` carries a premise disclosure (no component matching '<name>' exists in the vault \u2014 verify the name), so a nonexistent component is never answered as if it were real. Honesty routes: asks the surface genuinely cannot answer route to an explicit gap, never a lookalike tool \u2014 e.g. 'which USERS hold permission set X' (PermissionSetAssignment is not modeled; effective_permissions describes GRANTS, not holders); profile user-roster asks route live (live_group_count over User by ProfileId) with a partial-answer disclosure; and cross-vault compare routes carry a disclosure that a SECOND registered vault is required, so a single-vault install is never routed confident-clean into vault-not-found. Tells you when to sfi.resolve a named component first, whether the opt-in live plane is required, surfaces `suggestedArgs` (heuristic per-intent hints \u2014 e.g. `event: 'update'` for a save-order question so you can call `what_happens_on_save` without guessing the DML event), and \u2014 when the question hits a capability we lack \u2014 returns an honest 'unknown'/gap instead of fabricating (set `logGap: true` to also append the gap to the local backlog; off by default, privacy-first per CR-16); under `SFI_TOOL_PROFILE=core` the response also carries `invoke`: the routed tools as EXECUTABLE calls (core tools direct, everything else as the byte-identical `sfi.run_analysis` gateway envelope, suggestedArgs threaded) (a short phrase that merely NAMES a real vault component, with no question, is instead routed to sfi.resolve rather than 'unknown'). In the default hybrid mode the meaning-ranked `toolCandidates` are PRIMARY: every routable question carries the shortlist (offline TF-IDF over the capability map, no neural model, no network) plus a `guidance` line stating the loop YOU own \u2014 read the candidates \u2192 resolve any named component \u2192 pick/sequence the tool(s) \u2192 run them \u2192 ground via sfi.synthesize_answer. YOU decide which to run; the deterministic `route` rides along only as a non-authoritative HINT (suggested tool order + any resolved entity / suggestedArgs). Set `SFI_ROUTER_MODE=offline` for a deterministic, no-LLM route (Design A) where the route is authoritative and candidates are omitted \u2014 for CI / air-gapped hosts. An optional `mode` ('ask' | 'plan' | 'assessment') tailors the guidance and reranks the candidates toward that mode's family \u2014 'plan' favors the what_if_* / impact tools (an ordered change plan), 'assessment' favors the *_risk_report / readiness / coverage tools (a full evaluation), 'ask' is a quick grounded answer. Call this first on a vague/broad question to decide which tool(s) to run.",
65695
66920
  inputSchema: ROUTE_QUESTION_INPUT_SCHEMA
65696
66921
  },
65697
66922
  {
@@ -65721,7 +66946,7 @@ var init_tools = __esm({
65721
66946
  },
65722
66947
  {
65723
66948
  name: "sfi.list_components",
65724
- description: 'List components of a given type (optionally narrowed by parentId), sorted by id. Paginated via limit/offset; `hasMore` hints at additional pages (a truncated page returns a `nextCursor` to resume). For `type: \'ApexClass\'`, optional boolean filters list interface/async/API implementers at the DB layer (correct pagination, not a post-filtered page): `isBatchable` / `isQueueable` / `isSchedulable` / `isRestResource` / `hasFutureMethod` / `hasInvocableMethod` / `hasAuraEnabledMethod` / `isTest` \u2014 e.g. `{ type: \'ApexClass\', isBatchable: true }` returns every Batchable class. When manifest coverage for the requested `type` is not `complete`, a structured `coverageCaveat` flags the inventory as potentially incomplete (scoped refresh, errored retrieve, not modeled) \u2014 including on non-empty pages. When the FIRST page is empty, a `retrievalHint` (FRESH-02) says WHY \u2014 "none in the org" (retrieved, none found) vs "not retrieved" (a scoped refresh skipped the type \u2014 run /sfi-refresh) vs "not modeled" \u2014 so an empty list is never a silent `[]` read as "the org has none". (The hint is suppressed when a boolean filter is active, since an empty filtered result is not a coverage gap.)',
66949
+ description: 'List components of a given type (optionally narrowed by parentId), sorted by id. Paginated via limit/offset; `hasMore` hints at additional pages (a truncated page returns a `nextCursor` to resume). Grant-heavy rows (Profile / PermissionSet, whose nodes carry tens of KB of declarative grants) are slimmed to scalar properties \u2014 each such row is marked `properties.propertiesTruncated: true` and the page carries a top-level `propertiesSlimmed: true` \u2014 so the whole inventory fits per page; fetch full detail per component via sfi.get_component. For `type: \'ApexClass\'`, optional boolean filters list interface/async/API implementers at the DB layer (correct pagination, not a post-filtered page): `isBatchable` / `isQueueable` / `isSchedulable` / `isRestResource` / `hasFutureMethod` / `hasInvocableMethod` / `hasAuraEnabledMethod` / `isTest` \u2014 e.g. `{ type: \'ApexClass\', isBatchable: true }` returns every Batchable class. When manifest coverage for the requested `type` is not `complete`, a structured `coverageCaveat` flags the inventory as potentially incomplete (scoped refresh, errored retrieve, not modeled) \u2014 including on non-empty pages. When the FIRST page is empty, a `retrievalHint` (FRESH-02) says WHY \u2014 "none in the org" (retrieved, none found) vs "not retrieved" (a scoped refresh skipped the type \u2014 run /sfi-refresh) vs "not modeled" \u2014 so an empty list is never a silent `[]` read as "the org has none". (The hint is suppressed when a boolean filter is active, since an empty filtered result is not a coverage gap.)',
65725
66950
  inputSchema: LIST_COMPONENTS_INPUT_SCHEMA
65726
66951
  },
65727
66952
  {
@@ -65941,7 +67166,7 @@ var init_tools = __esm({
65941
67166
  },
65942
67167
  {
65943
67168
  name: "sfi.effective_permissions",
65944
- description: "Compute a user's EFFECTIVE access \u2014 the UNION of a profile + assigned permission sets, max-wins, with each permission attributed to the container(s) that grant it. `why_cant_user_see_record` evaluates a single record question against a bundle; nothing else rolls the containers up into one combined ability \u2014 this does. Input: `profileId` and/or `permissionSetIds[]` (at least one). A `PermissionSetGroup:` id may be passed in `permissionSetIds[]` \u2014 it is EXPANDED into its member permission sets (declared membership) and unioned in, so a PSG-assigned user gets a real answer (a permset reachable both directly and via a group is unioned once, not double-counted). It composes each container's outgoing `grantedBy` edges (object + field + apex) and `properties.userPermissions` (system perms). `objectPermissions[]` carries the OR'd `allowCreate`/`allowRead`/`allowEdit`/`allowDelete`/`viewAllRecords`/`modifyAllRecords` per object plus `grantedBy` (the containers contributing a flag); `systemPermissions[]` lists each user-permission with its `grantedBy`; `customPermissions[]` (CR-CAP-10) lists each granted custom permission with its `grantedBy` + `targetMissing` (true when the granted name has no `CustomPermission` definition in the vault \u2014 managed-package / not-retrieved; declared but not resolvable, and NOT folded into systemPermissions); `summary` reports objects / fieldsWithFls / apexClasses / systemPermissions / customPermissions counts. The object list PAGES (`limit` default 100 / max 200, `offset`/`hasMore`/`truncated`). `declared` confidence. `disclosures` is explicit about the boundaries: permission-set GROUP membership IS expanded, but muting permission sets are DISCLOSED, not subtracted (effective access may be lower); app/tab visibility is a SEPARATE surface (now extracted \u2014 see `app_access` / `tab_availability`), not part of this union; field-level detail is summarised (use `field_access_audit`); object permission is NOT record access (record visibility needs OWD + sharing); custom permissions are declared grants, NOT system userPermissions, so they are never double-counted. Missing containers are ignored with a disclosure; if none exist \u2192 `component-not-found`.",
67169
+ description: "Compute a user's EFFECTIVE access \u2014 the UNION of a profile + assigned permission sets, max-wins, with each permission attributed to the container(s) that grant it. `why_cant_user_see_record` evaluates a single record question against a bundle; nothing else rolls the containers up into one combined ability \u2014 this does. Input: `profileId` and/or `permissionSetIds[]` (at least one). A `PermissionSetGroup:` id may be passed in `permissionSetIds[]` \u2014 it is EXPANDED into its member permission sets (declared membership) and unioned in, so a PSG-assigned user gets a real answer (a permset reachable both directly and via a group is unioned once, not double-counted). It composes each container's outgoing `grantedBy` edges (object + field + apex), `properties.userPermissions` (system perms), and `properties.recordTypeVisibilities` (record-type visibility). `objectPermissions[]` carries the OR'd `allowCreate`/`allowRead`/`allowEdit`/`allowDelete`/`viewAllRecords`/`modifyAllRecords` per object plus `grantedBy` (the containers contributing a flag); `systemPermissions[]` lists each user-permission with its `grantedBy`; `customPermissions[]` (CR-CAP-10) lists each granted custom permission with its `grantedBy` + `targetMissing` (true when the granted name has no `CustomPermission` definition in the vault \u2014 managed-package / not-retrieved; declared but not resolvable, and NOT folded into systemPermissions); `recordTypeVisibilities[]` unions each container's declared record-type visibility (max-wins \u2014 visible=true wins; `<visible>` omitted in older metadata counts as visible, only an explicit false hides), each entry `{recordType, visible, grantedBy}` \u2014 record-type visibility is part of THIS union now, no longer only the separate `recordtype_availability` surface (that tool remains for the per-object grouped view); a container carrying no extracted `recordTypeVisibilities` property (a vault refreshed before record-type extraction) contributes nothing and is DISCLOSED (re-run /sfi-refresh), never fabricated as 'no record types'; `summary` reports objects / fieldsWithFls / apexClasses / systemPermissions / customPermissions / recordTypeVisibilities counts. The object list PAGES (`limit` default 100 / max 200, `offset`/`hasMore`/`truncated`). `declared` confidence. `disclosures` is explicit about the boundaries: permission-set GROUP membership IS expanded, but muting permission sets are DISCLOSED, not subtracted (effective access may be lower); app/tab visibility is a SEPARATE surface (now extracted \u2014 see `app_access` / `tab_availability`), not part of this union; field-level detail is summarised (use `field_access_audit`); object permission is NOT record access (record visibility needs OWD + sharing); custom permissions are declared grants, NOT system userPermissions, so they are never double-counted. Missing containers are ignored with a disclosure; if none exist \u2192 `component-not-found`.",
65945
67170
  inputSchema: EFFECTIVE_PERMISSIONS_INPUT_SCHEMA
65946
67171
  },
65947
67172
  {
@@ -66480,7 +67705,7 @@ var init_tools = __esm({
66480
67705
  },
66481
67706
  {
66482
67707
  name: "sfi.field_mapping_between_objects",
66483
- description: 'Map the fields of one object onto another for a conversion or migration \u2014 which field on object A corresponds to which field on object B, and which fields have no match and would lose data. Answers "map {A} fields to {B} for conversion", "which {A} fields lose data converting to {B}", "what\'s the field mapping between these two objects" (the classic Lead-to-Contact conversion mapping). v3.1 Q174 honesty-anchor tool: given a single vault alias and TWO CustomObject api-names, returns a heuristic field pairing for migration mapping (the Lead-vs-Contact conversion case). Each `FieldPair` carries `fieldA` / `fieldB` shape (apiName, label, type), the Jaccard `labelSimilarity` over tokenized api-name + label tokens, the `typeCompatible` flag from the static type-compatibility table (text\u2194text, number\u2194number, date\u2194date, picklist\u2194picklist, reference\u2194reference), and `confidence: \'heuristic\'`. Optional `similarityThreshold` (default 0.50) suppresses pairs below the floor; `includeTypeIncompatible: true` retains label-matched pairs whose types disagree (each flagged `typeMismatch: true`). `unpairedFromA` / `unpairedFromB` list fields without a suggested match. `boundaries[]` ALWAYS surfaces the verbatim Q174 phrase: \'field-mapping suggestions are heuristic \u2014 labels are matched by token overlap and types by compatibility table. Verify each suggested pair against your business rules before relying on the mapping for a migration script.\' A v3.1 release without this phrase is a contract violation regardless of test-suite green (PLAN-v3.1 \xA710 constitutional axis).',
67708
+ description: "Map the fields of one object onto another for a conversion or migration \u2014 which field on object A corresponds to which field on object B, and which fields have no match and would lose data. Answers \"map {A} fields to {B} for conversion\", \"which {A} fields lose data converting to {B}\", \"what's the field mapping between these two objects\" (the classic Lead-to-Contact conversion mapping). v3.1 Q174 honesty-anchor tool: given TWO CustomObject api-names, returns a heuristic field pairing for migration mapping (the Lead-vs-Contact conversion case). `vault` is OPTIONAL \u2014 omit it (the normal single-vault install) to map within the SERVED vault; supply a registered alias only in a multi-vault-registry deployment. With no registry, a self-referential alias (the served vault's directory name or path) still answers from the served vault with a single-vault disclosure; any other alias gets an honest alias-not-found refusal plus the omit-`vault` hint. Each `FieldPair` carries `fieldA` / `fieldB` shape (apiName, label, type), the Jaccard `labelSimilarity` over tokenized api-name + label tokens, the `typeCompatible` flag from the static type-compatibility table (text\u2194text, number\u2194number, date\u2194date, picklist\u2194picklist, reference\u2194reference), and `confidence: 'heuristic'`. Optional `similarityThreshold` (default 0.50) suppresses pairs below the floor; `includeTypeIncompatible: true` retains label-matched pairs whose types disagree (each flagged `typeMismatch: true`). `unpairedFromA` / `unpairedFromB` list fields without a suggested match. `boundaries[]` ALWAYS surfaces the verbatim Q174 phrase: 'field-mapping suggestions are heuristic \u2014 labels are matched by token overlap and types by compatibility table. Verify each suggested pair against your business rules before relying on the mapping for a migration script.' A v3.1 release without this phrase is a contract violation regardless of test-suite green (PLAN-v3.1 \xA710 constitutional axis).",
66484
67709
  inputSchema: FIELD_MAPPING_BETWEEN_OBJECTS_INPUT_SCHEMA
66485
67710
  },
66486
67711
  // v3.2 — OmniStudio composition tier. The
@@ -66550,7 +67775,7 @@ var init_tools = __esm({
66550
67775
  },
66551
67776
  {
66552
67777
  name: "sfi.find_component_usages",
66553
- description: 'The universal "where is this component used?" answer for ANY canonical component type (`componentId`) \u2014 one entry point instead of fanning out across find_field_anywhere / find_code_usages / get_impact / grep. Composes two evidence tiers: (1) GRAPH \u2014 incoming dependency edges to the target, grouped by referrer type, each carrying edge `confidence`, EXCLUDING access grants (`grantedBy`) and structural `parentOf` (access is not usage); (2) GREP supplement (`text-match` tier, `includeGrep` default true) \u2014 a literal search of Apex AND frontend bundle source (LWC/Aura/Visualforce \u2014 `$Label`/`$Resource`/`@salesforce` module references) for the api name, catching references the graph does not model (dynamic SOQL, reflective access, CustomMetadataType / CustomLabel / StaticResource refs). `graphReferrers[]` (type + count + sample), `grepSupplement` (matches with path/line/snippet), `summary` (counts + `hasStaticEvidence`), `boundaries[]`, `truncated`. HONESTY: empty graph + empty grep = "no static evidence in the vault" (in `boundaries`), NEVER "nothing uses this" \u2014 dynamic constructs, un-modeled families (reports/dashboards/list-views), and managed packages are invisible. Phantom-aware (a referenced-but-not-retrieved target still answers from its edges). Specialized tools (find_field_anywhere, layout_assignments, \u2026) stay for a deeper single-family answer; this unifies the common case. Non-canonical id \u2192 `invalid-query`; an id with no node AND no referrers \u2192 `component-not-found`.',
67778
+ description: 'The universal "where is this component used?" answer for ANY canonical component type (`componentId`) \u2014 one entry point instead of fanning out across find_field_anywhere / find_code_usages / get_impact / grep. Composes two evidence tiers: (1) GRAPH \u2014 incoming dependency edges to the target, grouped by referrer type, each carrying edge `confidence`, EXCLUDING access grants (`grantedBy`) and structural `parentOf` (access is not usage); (2) GREP supplement (`text-match` tier, `includeGrep` default true) \u2014 a literal search of Apex AND frontend bundle source (LWC/Aura/Visualforce \u2014 `$Label`/`$Resource`/`@salesforce` module references) for the api name, catching references the graph does not model (dynamic SOQL, reflective access, CustomMetadataType / CustomLabel / StaticResource refs). `graphReferrers[]` (type + count + sample), `grepSupplement` (matches with path/line/snippet), `summary` (counts + `hasStaticEvidence`), `boundaries[]`, `truncated`. ACCESS-GRANT section: grants stay OUT of `graphReferrers`, but a separate `grantedBy` section (`{count, granters[{id,type}]}`) surfaces the granting containers when the target is a `CustomPermission` (its only incoming edges ARE grants \u2014 this answers "which Profiles / PermissionSets grant it?"; always present for that type, count 0 = no container grants it) or when the target has zero usage edges but incoming `grantedBy` edges; absent otherwise. HONESTY: empty graph + empty grep = "no static evidence in the vault" (in `boundaries`), NEVER "nothing uses this" \u2014 dynamic constructs, un-modeled families (reports/dashboards/list-views), and managed packages are invisible; grants are NOT usage, so `grantedBy` never counts toward `hasStaticEvidence`. Phantom-aware (a referenced-but-not-retrieved target still answers from its edges, including a granted-but-undefined managed-package CustomPermission). Specialized tools (find_field_anywhere, layout_assignments, \u2026) stay for a deeper single-family answer; this unifies the common case. Non-canonical id \u2192 `invalid-query`; an id with no node, no referrers AND no grants \u2192 `component-not-found`.',
66554
67779
  inputSchema: FIND_COMPONENT_USAGES_INPUT_SCHEMA
66555
67780
  },
66556
67781
  {
@@ -68630,7 +69855,7 @@ var init_package_version = __esm({
68630
69855
  "use strict";
68631
69856
  readCliPackageVersion = () => {
68632
69857
  if (true)
68633
- return "0.1.20";
69858
+ return "0.1.21";
68634
69859
  for (const rel of ["../package.json", "../../package.json"]) {
68635
69860
  try {
68636
69861
  const raw = readFileSync3(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
@@ -68647,7 +69872,7 @@ var init_package_version = __esm({
68647
69872
 
68648
69873
  // dist/src/refresh-pipeline.js
68649
69874
  import { mkdir as mkdir10, readdir as readdir5, stat as stat7, writeFile as writeFile9 } from "node:fs/promises";
68650
- import { basename as basename13, dirname as dirname20, join as join28, sep as sep3 } from "node:path";
69875
+ import { basename as basename14, dirname as dirname20, join as join28, sep as sep3 } from "node:path";
68651
69876
  var EXTRACT_CACHE_VERSION, SUPPORTED_TYPES, EXTRACTORS, dispatchFile, BUNDLE_PARENT_DIRS, walkDir, relativeSegments, DX_WRAPPER_SEGMENTS, KNOWN_SIDECAR_SUFFIXES, isKnownSidecar, skipAttributionKey, componentTypeFromSourcePath, FOLD_TO_FIELD_USAGE, foldReportDashboardUsageIntoFields, walkAndExtract, composeDocument, parentApiNameFor, writeNodeDocument, writeIndex, RENDER_PAGE_SIZE, renderVault, parseTypeFilter;
68652
69877
  var init_refresh_pipeline = __esm({
68653
69878
  "dist/src/refresh-pipeline.js"() {
@@ -68954,7 +70179,7 @@ var init_refresh_pipeline = __esm({
68954
70179
  return;
68955
70180
  }
68956
70181
  const sorted = entries.filter((entry) => !entry.name.startsWith(".")).sort((a2, b2) => a2.name < b2.name ? -1 : a2.name > b2.name ? 1 : 0);
68957
- const currentName = basename13(currentDir);
70182
+ const currentName = basename14(currentDir);
68958
70183
  const isBundleParent = BUNDLE_PARENT_DIRS.has(currentName);
68959
70184
  for (const entry of sorted) {
68960
70185
  const abs = join28(currentDir, entry.name);
@@ -69005,7 +70230,7 @@ var init_refresh_pipeline = __esm({
69005
70230
  };
69006
70231
  componentTypeFromSourcePath = (sourceRoot, absPath, isDirectory = false) => {
69007
70232
  const segments = relativeSegments(sourceRoot, absPath);
69008
- const fileName = isDirectory ? basename13(absPath) : segments[segments.length - 1] ?? basename13(absPath);
70233
+ const fileName = isDirectory ? basename14(absPath) : segments[segments.length - 1] ?? basename14(absPath);
69009
70234
  const dirSegments = isDirectory ? segments : segments.slice(0, -1);
69010
70235
  return dispatchFile(dirSegments, fileName, isDirectory);
69011
70236
  };
@@ -69203,7 +70428,7 @@ ${body}
69203
70428
 
69204
70429
  // dist/src/source-reconcile.js
69205
70430
  import { cp, readdir as readdir6, rm as rm3, stat as stat8 } from "node:fs/promises";
69206
- import { basename as basename14, join as join29, relative as relative3, sep as sep4 } from "node:path";
70431
+ import { basename as basename15, join as join29, relative as relative3, sep as sep4 } from "node:path";
69207
70432
  var BUNDLE_PARENT_DIRS2, KNOWN_SIDECAR_SUFFIXES2, isKnownSidecar2, primaryRelPathForSidecar, walkSourceEntries, authoritativePathSet, reconcileSourceDeletions, syncAuthoritativeRetrieveIntoSource;
69208
70433
  var init_source_reconcile = __esm({
69209
70434
  "dist/src/source-reconcile.js"() {
@@ -69239,7 +70464,7 @@ var init_source_reconcile = __esm({
69239
70464
  return;
69240
70465
  }
69241
70466
  const sorted = entries.filter((entry) => !entry.name.startsWith(".")).sort((a2, b2) => a2.name < b2.name ? -1 : a2.name > b2.name ? 1 : 0);
69242
- const isBundleParent = BUNDLE_PARENT_DIRS2.has(basename14(currentDir));
70467
+ const isBundleParent = BUNDLE_PARENT_DIRS2.has(basename15(currentDir));
69243
70468
  for (const entry of sorted) {
69244
70469
  const abs = join29(currentDir, entry.name);
69245
70470
  if (entry.isDirectory()) {
@@ -69296,7 +70521,7 @@ var init_source_reconcile = __esm({
69296
70521
  for (const entry of sourceEntries) {
69297
70522
  if (!typesToReconcile.has(entry.type))
69298
70523
  continue;
69299
- const fileName = basename14(entry.absPath);
70524
+ const fileName = basename15(entry.absPath);
69300
70525
  const primaryRel = primaryRelPathForSidecar(entry.relPath, fileName);
69301
70526
  const authoritativeKey = primaryRel ?? entry.relPath;
69302
70527
  if (authoritative.has(authoritativeKey) || authoritative.has(entry.relPath))
@@ -138554,7 +139779,7 @@ var makeShutdownOnce = (ctx) => {
138554
139779
 
138555
139780
  // dist/src/commands/demo.js
138556
139781
  init_refresh();
138557
- var buildVersion = () => true ? "0.1.20" : "dev";
139782
+ var buildVersion = () => true ? "0.1.21" : "dev";
138558
139783
  var SHUTDOWN_SIGNALS2 = ["SIGINT", "SIGTERM"];
138559
139784
  var resolveDemoSource = () => {
138560
139785
  let dir = dirname21(fileURLToPath2(import.meta.url));
@@ -139539,7 +140764,7 @@ init_vault_git();
139539
140764
  init_watch();
139540
140765
  var readVersion = () => {
139541
140766
  if (true)
139542
- return "0.1.20";
140767
+ return "0.1.21";
139543
140768
  const pkgUrl = new URL("../../package.json", import.meta.url);
139544
140769
  const raw = readFileSync6(fileURLToPath3(pkgUrl), "utf8");
139545
140770
  const parsed = JSON.parse(raw);