Releases: PranavNagrecha/Salesforce-Intelligence
Release list
v0.3.2
Added
titleandwebsiteUrlon the MCP Registry record.packages/cli/server.json— the manifest the release workflow publishes to the official MCP Registry on everyv*tag — carriedname,description,status,repository,versionandpackages, and nothing else. The published record had no human-readable name beyond the reverse-DNS idio.github.PranavNagrecha/sf-intelligence, and the GitHub repo as its only link; the documentation site appeared nowhere in it. The record now sets atitleandwebsiteUrl(https://sfi.auditforce.cloud). 0.3.2 is the first release that PUBLISHES them: the fields were added 70 minutes after thev0.3.1tag was already cut, and the registry rejects a duplicate version outright, so they could only reach the registry with the next tag.- Setup mode: the MCP server now always connects, even with no vault. Previously
sfi mcpwrote its "runsfi init" guidance to stderr and exited 1, which every MCP host renders as "server failed to connect" — the one message that would have unblocked the user went to a channel the user cannot see. The server now boots and exposessfi.setup_status, a read-only tool reporting where it looked for a vault, which orgs are authenticated, and the exact ordered commands to build one, so the assistant can walk the user through setup from inside the chat. docs/guides/mcp-hosts.md— per-host, per-platform connection guide for Claude Code, Claude Desktop, Codex, and VS Code + GitHub Copilot on macOS and Windows: exact config paths, exact blocks, log locations, and an ordered "when it does not connect" checklist.
Changed
sfi initnow requires--target-orgwhen stdin is not a terminal. This is breaking for any script or automation that relied on the default-org fallback; pass the alias explicitly.- Six hand-rolled spellings of "split a path" / "render a path relative to" across five packages are replaced by one module,
@sf-intelligence/core'spath-portable. The rule it encodes: the number of correct spellings is one.toPosixPath(unconditional) andtoRelativePosix(host-gated) are deliberately kept distinct — the vault'ssourceTreeHashdepends on the gated form, and merging them would make every existing vault report stale. A pinned-digest test now locks those bytes. - The Windows CI job runs again, on every push. It had been
if: github.event_name == 'workflow_dispatch'— never running on push or PR — and excluded four tests plus three whole CLI test files. The gate was disarmed at precisely the files carrying the Windows defects this release fixes. It now runs the full unit suite on every push. It is ADVISORY for the moment — its first real runs surfaced runner-performance failures (a DuckDB-backed test at 48s against a 45s ceiling; the 10k-node scale import at ~99s against 90s; graph fixtures thatrmdirwithout closing their DuckDB handle) which are not product bugs and need work in the graph/test layer. Advisory rather than excluded, so the debt stays visible in the log instead of being silently filtered the way the whole job used to be. - The platform-fragile tests are no longer hidden by CI-only
-t/--excludefilters. Genuinely POSIX-only fixtures (a#!/bin/shscript, a0600file mode, aCOMSPEC-stubbed win32 simulation) aredescribe.skipIf(process.platform === 'win32')in their own source, where a reader can see them; the rest now derive their expectations fromnode:pathinstead of hardcoding a POSIX rendering. - New
pnpm check:portabilitygate (wired into CI): fails the build if hand-rolled path-separator logic reappears in a package source tree. The four legitimate exceptions are allowlisted with their reasons.
Fixed
- Nine tools advertised an input shape their own validators do not enforce. Every
sfi.*tool carries TWO input contracts — the Zod schema its handler validates against, and the JSON Schema an MCP host reads out oftools/list— and where they disagree, a host that validates arguments before sending them refuses calls the handler would have served. Three were flagged on therequiredaxis:sfi.get_componentadvertisedidas required while the already-advertisedcomponentIdalias satisfies it through the preprocess,sfi.explain_erroradvertisederrorTextwhileerror/message/errorMessage/texteach satisfy it, andsfi.explain_debug_logadvertisedlogTextwhiledebugLog/log/text/contentdo. Those aliases exist BECAUSE hosts guess those names, so the advertisement refused exactly the call the design anticipates. Six more tools accepted keys no schema-driven host could see:componentId(interchangeable withfieldIdat the validator) onsfi.explain_fieldandsfi.field_access_audit,eventApiNameonsfi.event_subscribers,staticOnlyonsfi.unused_fields_deep, and the object selectors onsfi.layout_assignmentsandsfi.automation_collisions.sfi.automation_collisionswas the worst of them: its advertised schema pairedadditionalProperties: falsewithrequired: ['object'], socomponentId/objectApiName/objectIdwere not merely invisible — the advertisement declared them INVALID. Sevenrequiredlists shrank in all, not three, because advertising an alias is what makes the old key omittable:required: ['fieldId']came offsfi.explain_fieldandsfi.field_access_audit,required: ['componentId']offsfi.layout_assignments,required: ['object']offsfi.automation_collisions. No handler and no validator changed — this is the advertisement catching up to what the code already accepts. Two costs are recorded rather than hidden. JSON Schemarequiredcannot express "either key", so those schemas now UNDER-claim: a call naming neither key is advertised as valid and still fails at the validator with a named error. And the two object selectors added an advertisedobjectId, which the ADR-007 id-naming gate reads as fresh drift — grandfathered inresponse-consistency-baseline.jsonbeside the 21 sibling tools that already carried it. retrieveConfirmedwas set on 0 of 96 coverage rows — the flag that separates a CONFIRMED-empty family from one nobody ever retrieved was inert in the field built for it. Not a missing feature: a regression this project caused.stampFamilyEpochscarriesretrievedAtandepochforward across a--no-pullrebuild and silently droppedretrieveConfirmedalongside them, so a rebuild erased the evidence while leaving every row looking intact. It is carried forward now, and only when this pass gives no reason not to: never over aretrieveConfirmedthis pass set itself, an error it found, or apendinga decorator forced. Delivered in the same squash:pendingmeant two different things. "Not yet attempted" and "attempted, capped, real partial evidence" were one flag, so the usage-ranked report/dashboard pull (SFI_REPORTS_CAP) and the report-node persistence ceiling wrotepending: truebeside a non-zeroretrieved— a family with 388 items actually retrieved read as if nothing had run. A separatecappedstate now carries that case, with its own bucket insfi.coverage_report. Downstream behaviour is deliberately unchanged: a capped family is still excluded fromcovered, still folded intomissingCoverage, and every absence caveat still fires — only the REASON is now legible. A--no-pullor scoped refresh with no cap evidence of its own falls back to the previous manifest'sreportsCapblock rather than regressing the row to the fold-erased default.ConditionalContextwas required coverage it can never satisfy. It is a synthetic node the extractor mints while parsing a firer's condition — never a familysf project retrievepulls — sobuildCoverageEntriesnever writes it a coverage row. Naming it inVALUE_LITERAL_READER_COVERAGEmade that list permanently unsatisfiable, poisoningmissingCoveragefor its two consumers,sfi.value_change_auditandsfi.what_if_remove_picklist_value: every answer hedged against a gap no refresh could ever close. Replaced by the firer families that actually producefiresWhenedges —ApprovalProcess,AutoResponseRule,AssignmentRule,EscalationRulejoin theWorkflowRule/ValidationRule/Flowalready listed. Both consumer fixtures had hand-copied the list and invented aConditionalContextcoverage row no real vault has, which is exactly how it went unnoticed; they now import the shared constant.sfi.find_component_usagescounted a component's own declaration as evidence something uses it. The grep supplement matched a class's ownclass Fooline exactly as a caller's reference would, so a component with zero graph referrers and one grep hit — its own declaration — reportedhasStaticEvidence: true, in the tool people consult before deleting things. Self-matches (the declaring file, or any file in the component's own LWC / Aura / Visualforce bundle directory) are now excluded beforematchCountandhasStaticEvidenceare computed, counted in a newselfMatchesExcluded, and named inboundaries[]so "grep ran and found only its own declaration" cannot be read as "grep ran and found nothing".sfi.who_can_access_objectshipped half a page and called it whole. The handler sized its page tolimitbut not to the response byte budget, so the envelope's blind tail-truncation cutgrantersout from under the already-computedhasMore/truncated: 218 real rows delivered as 109, withhasMoreandtruncatedbothfalse, so nothing told the caller there was more to page for. The completedatapayload is now fit to the budget by binary search before the envelope sees it, sohasMore/truncated/offsetdescribe the rows actually shipped. Sharing-rule rows also gainedsourceRuleId:vianames the rule TYPE, not the rule, so two different rules of the same type sharing with the same principal collided...
v0.3.1 — the tools audit themselves
A correctness release against tools that were already shipped. No new capability tier.
Three adversarial audits went through the permissions, schema-and-search, and automation tool families, hunting only tools that predated the work. They produced 39 verified defects — each reproduced against a real org vault, measured for scale, and checked against a sibling tool answering the same question. Then a review pass and an adversarial QA pass found 18 more that the fixes themselves introduced, all of them invisible to a green suite.
Both numbers are reported here on purpose. A release note claiming a clean sweep, from the same work that shipped the original 39, would not be credible.
What moved
| Measurement | Before | After |
|---|---|---|
| Apex classes reported likely dead, of 186 | 85 | 0 |
find_dead_code likely_dead (both vaults) |
18 | 2 |
definitely_dead at org scale (the control) |
1041 / 979 | unchanged |
| Classes reported as having no test | 58 | 19 |
| Save-order steps returned for a busy object | 5 of 57, marked complete | 57 of 57 |
| Share of a save response spent on the answer | 12% | 78% |
| Rows reachable by paging a code-quality audit | 94 of 647 | 647 |
| Rows reachable by paging an unused-field audit | 281 of 570 | 570 |
| Trigger-deactivation verdicts carrying information | 0 of 22 | 22 of 22 |
| "What runs when I save a contact?" answered end to end | no | yes |
Four worth naming
A verdict justified by something untrue about Salesforce. find_dead_code told you it was safe to delete classes that run on a schedule, reasoning that such a class "must be enqueued/executed/scheduled by user Apex." An admin scheduling through Setup creates a CronTrigger record — data, not metadata — never retrieved, no node, no edge, and no refresh can ever close that gap. 16 of 18 likely_dead verdicts on each vault were exactly those classes. They are now uncertain; definitely_dead is unchanged, so the control held.
A stale retrieval overwriting the current one. A vault can hold two complete retrievals. Node writes replace on conflict and the directory walk is alphabetical, so the older copy won. On a real org, three profiles reported an MFA-bypass permission the current retrieval no longer declared. The canonical layout now wins and every conflicted component carries a sourceConflict field naming both paths and how the answer was chosen — or says precedence: undetermined rather than guessing.
The honesty payload was being truncated. The response trimmer learned to descend a level, which made the disclosure arrays reachable — the lists of what a tool did not check — and began shortening them under a note claiming a total that was never published. Disclosures are now never trim candidates; an oversize response refuses and names real narrowing knobs instead.
Resume pointers that skipped whole windows. A pointer computed from what a handler intended to return, rather than what survived a second trim, made rows unreachable by any call. Pointers are now corrected only on a positive match against a page size the payload itself publishes, and invalidated with a stated reason otherwise.
What was actually wrong, underneath
Almost every defect was a second copy of something — two same-named constants, a block duplicated under a comment promising byte-identity, a hand-copied JSON Schema beside the validator that enforces, two rule counters, two byte limits that had to stay ordered and were set independently in the wrong order. Every one written by someone who knew. The comment was the mechanism, and comments do not hold.
So the durable part of this release is not the fixes:
- an advertised-vs-enforced schema parity gate over all 217 tools on four axes, with 27 pre-existing violations baselined and reasoned rather than hidden
- a tool-local byte budget derived from the global one, so the ordering holds by construction
- drift tests running two implementations of a shared predicate against one fixture
- a documentation pin that could not fire — its pattern could not match across a line break, so a stale count sat beside three correct ones and the file reported clean
Upgrading
No tool's input contract breaks an existing call. Ten tools gained advertised inputs they already accepted, one dropped an input that was a pure synonym for the canonical one, and several now refuse selector combinations that previously resolved silently to something you did not ask for.
If your vault holds more than one retrieval of the same org — a source tree with both a flat and an SFDX layout — rebuild it. Until you do, the tools will tell you which components are affected instead of quietly answering from whichever copy sorted last.
11,412 tests green. Full detail for this release is in CHANGELOG.md — the complete section runs to ~140,000 characters, past GitHub's release-note limit, so this page is a summary.
Release post: https://sfi.auditforce.cloud/blog/sf-intelligence-0-3-1
v0.3.0
Added
- Supply-chain hardening (AUDIT-F10). Consumer guide
docs/guides/supply-chain.md(pinned install, provenance verify, SBOM);
scripts/check-pack-allowlist.mjs/pnpm pack:checkenforces
packages/clifilesas the published tarball allowlist (wired into
prepublishOnly); tag publish attaches a CycloneDX SBOM to the GitHub
Release. - Verdant public truth gate (AUDIT-F7). Hand-authored
examples/demo-vault/truth/manifest.jsonplus
sf-intelligence-qa/scripts/verdant-truth.mjs(inventory + design-goal tool
pins + mutation self-test). Wired asharness:verdant-truthin the commit
gate. Multi-org expansion and full scorecards deferred. - Untrusted org-metadata branding (AUDIT-F8). Contracts expose
UntrustedOrgText/ContentPolicy/ORG_METADATA_CONTENT_POLICY.
sfi.get_componentandsfi.resolveadd additivelabelOrgText/
descriptionOrgTextfields; the MCP dispatcher stampscontentPolicyon
success envelopes so hosts treat org strings as data, never instructions or
consent. Markdown escaping remains a renderer concern
(escapeMarkdownInlineexported from@sf-intelligence/renderers). - The zero-friction demo is now discoverable.
npx -y sf-intelligence demoserves a synthetic org with no Salesforce auth, nosfCLI and nothing to configure, but it appeared on only 2 of 34 site pages and in zero occurrences acrossllms.txt/llms-full.txt— so an assistant reading the site saw only a three-prerequisite wall and had reason to hedge before recommending the tool. It is now on/mcp, every use-case page, all comparison pages and inllms.txt, including a generic stdio config for Claude Desktop, Cursor and Codex that previously existed nowhere (the demo was documented for Claude Code only). - Sitemap entries now carry
lastmod, sourced from each page's real git commit date — the one sitemap signal Google uses for recrawl scheduling, previously absent on all 34 URLs whilechangefreqandpriority(both documented as ignored) were set on every one. Deliberately falls back to filesystem mtime and then to omitting the field rather than stamping build time, which would make the signal a lie Google learns to discount. - Structured data:
WebPage,PersonandImageObjectnodes on every page, so a graph is actually bound to the URL being parsed. Fixes/mcpemitting a typed-but-emptySoftwareApplication—@idscope is per-document, so referencing the home page's entity by id produced the site's only invalid item, on its best-ranking page.og:type,datePublishedanddateModifiedare now threadable from any page throughDocPageandBase. - Supply-chain provenance is now stated on the site: npm publishing is keyless OIDC with Sigstore attestations, verifiable via
npm audit signatures.
Changed
- EvidenceEnvelope v2 (AUDIT-F4). Shared output contract
(EvidenceEnvelopeV2in@sf-intelligence/contracts) for claims, evidence,
coverage, freshness, pagination, and absence verdicts. Opt-in projection
underdata.evidenceEnvelopeonsfi.interpretandsfi.safe_to_delete_field
(legacy keys unchanged). RuntimeassertEvidenceEnvelopeV2guards those
handlers; not applied roster-wide. - Retrieval ledger + family epochs (AUDIT-F5). Coverage rows carry per-family
retrievedAt/epoch(preserved across scoped--typesrefreshes). Refresh
writesmeta/retrieval-ledger.jsonand appendsmeta/tombstones.jsonlfor
confirmed reconcile deletions (never on refuse).TrustSummary.freshness
can discloseoverall: 'mixed'withfamilies/oldestEvidenceAt.
sfi.coverage_reportsurfaces tombstones + mixed-freshness limitations. - Core-by-default + strict invocation (AUDIT-F6). Default
SFI_TOOL_PROFILE
iscore(19-tool spine, incl.sfi.live_consent). Directtools/calloutside the advertised set is
denied under core — usesfi.run_analysis(target must be a registered tool).
sfi.describe_analysisgains progressivedetail(summary|schema|
full; defaultsummaryunder core). SetSFI_TOOL_PROFILE=fullfor the
previous advertise-everything behavior. - Tool catalog hygiene (AUDIT-F9). Scrubbed internal milestone / wave
language (R6-*,AUDIT-*,v2.x R2a…,P5-*, …) from MCP tool
descriptions sotools/listandlist_analysesone-liners read as product
jobs.list_analysesnow omitshiddenretired aliases (same advertise
contract astools/list; still invokable viarun_analysis). Structural
consolidations (−4 hidden aliases) were already shipped; further handler
merges deferred. - ProductManifest /
sfi.capabilitiesreportdefaultProfile: 'core',activeProfile, and anadvertisedcount that matchestools/listunder the active profile (full roster remains underprofiles.full). - SERVER_INSTRUCTIONS and capabilities routing guidance teach the core profile +
run_analysisgateway; they no longer tell hosts to callsfi.interpret/sfi.live_consentdirectly or to useliveEnabled: trueas consent. - Skills / agents / commands under default
SFI_TOOL_PROFILE=corenow
instruct hosts to invoke non-core analyses throughsfi.run_analysis
{ name, args }(Decision 2=C). Shared grounding footer + entry skill teach
the gateway;pnpm skill-gatewayfails CI on direct non-core Call/Fire
instructions.llms.txt/llms-full.txtand.claude-plugin/plugin.json
pinsf-intelligence@0.3.0(gated byverify-doc-sync); the website's own
install snippets stay unpinned sonpx -yresolves latest. Every surface
states core as the default, not full. - SBOM generation uses
@cyclonedx/cdxgenviapnpm sbom(pnpm-aware,
fail-closed). Tag publish attaches a non-empty CycloneDX 1.5 artifact or
fails the job — no more emptynpm sbomskip. - New article: "What does this Salesforce Flow do?" — reading a Flow you did not build. Documents that the concept model does not extract
triggerOrderand therefore cannot confirm whether Flow Trigger Order is configured, rather than implying the order is undefined. - The three thinnest comparison pages (
hubbl,dx0,metazoa— 314/328/347 words, all with zero search impressions) expanded past 1,150 prose words each, and a false claim corrected: the Hubbl page said MCP was "not the core public product story", but Hubbl ships an MCP server and documents it ("Connect AI assistants to Hubbl via the Model Context Protocol"). An unverifiable dx0 ISO certificate number was removed, and Metazoa pricing is now described qualitatively because they publish no list price. /use-cases/what-breaks-if-you-delete-a-fieldand/blog/delete-unused-salesforce-fieldswere competing for the same query with overlappingFAQPagequestions. The use-case page is narrowed to the product surface and itsFAQPageblock removed; the blog keeps informational intent. Sitewide there are now zero duplicate FAQ questions across 15 pages and 58 questions./use-cases/sharing-troubleshootingexpanded from ~230 to ~1,450 prose words, and a wrong tool name (why_cant) corrected towhy_cant_user_see_record.
Fixed
- A no-intent question could be dressed up as an advisory route. "any thoughts on the general vibe of the setup here" upgraded from an honest
unroutedtofunnel-advisory, topped bysfi.live_setup_audit_trail— attraction to the bare token "setup" (the English noun vs the Salesforce menu), not meaning. Measuring 30 no-intent × 20 advisory-tier questions showed the score threshold cannot fix it: the noise ceiling (0.436) sits above the advisory signal floor (0.261), and raising the bar past the noise leaves ~7 of 21 genuine advisories alive. The separable axis is evidence breadth — candidate mass at ranks 3-8, where noise spans [0.249, 0.277] and signal spans [0.376, 1.672]. A newFUNNEL_MIN_EVIDENCE_BREADTH(0.32, the centre of that gap) is now a second condition on the advisory upgrade: one lexical collision lights up a single tool and leaves the tail flat, while a real question spreads support across many index terms.FUNNEL_PRIMARY_MIN_SCOREstays 0.26 — the fix does not belong on that axis. Funnel-recall, the router goldset and the routing gate are unchanged. Partial, and measured as such: on a 30-question no-intent set this cuts spurious advisories from 6 to 2. It closes the single-token-collision class ("setup", "audit"). It does not close multi-token conversational asks — "how does this compare" still reachescompare_components, "can you help me out" still reachesdoc_coverage_report— because those words genuinely spread across the tool index, putting their breadth inside the range real questions occupy. Tightening past them would cut measured signal, so the remaining class needs an intent classifier, not a threshold. - A version tag published without any guarantee CI had run.
publish.ymlfires on tag push;ci.ymlfires on push-to-main/release/**and pull_request — never on tags. The two were fully decoupled, so tagging any commit shipped it to npm and the MCP Registry unverified. (needs:cannot close this — it only orders jobs within one workflow file.) The publish job's first step now asks the API what CI actually did on the tagged SHA and fails closed on everything except a clean success, including the real hole — "CI never ran" is never treated as a pass. It waits out an in-flight run rather than racing a fresh merge. - The published SBOM under-reported what consumers install.
cdxgenwas pointed atpackages/cli, which has nopnpm-lock.yaml— with nothing to resolve it fell back to that package.json alone and emitted 19 direct entries with an empty dependency graph. It now resolves the transitive runtime closure from the workspace lockfile: 137 components, 138 graph nodes. A component floor and a non-empty-graph check fail the publish rather than shipping a hollow ...
v0.2.5 — field-audit surface + delete-safety edge fixes + refresh data-loss fixes
Closes five ways sfi.safe_to_delete_field could report a field safe when the platform refuses to delete it, and fixes four pre-existing refresh bugs found along the way — one of which deleted the vault it was refreshing and reported success.
Delete-safety gaps closed
Every one sat inside a metadata family the refresh had fully retrieved, so no coverageCaveat fired and the verdict presented as clean rather than hedged. A tool can only warn about the gaps it knows it has.
| Gap | Symptom |
|---|---|
| Flow record-trigger entry criteria | Flow ships two XML spellings of one condition triplet; only <leftValueReference> was parsed. On the reference org: 449 filters across 160 of 275 flows, 100% in the unparsed <field> dialect |
| Roll-up coupling | Held as a node property, never an edge — declared on the parent, so a child-field walk never reached it |
| Condition field refs | Held as a property; firesWhen runs firer → context, so nothing reached the field |
Formula __r traversals |
Skipped — a field read only via Parent__r.Field__c showed zero referrers |
FlexiPage relatedListFieldAliases |
Bare names on the related object, invisible to the dotted sweep |
85 fields on the reference org had zero dependency evidence before this release.
Citation honesty
A correct verdict with invented evidence is still a defect. Fixed: a roll-up summary cited on 127 fields where none exists; a traversalPath promised in a note that no renderer emitted; two of three roll-up roles described; 4 of 7 condition firer families named; and one validation rule counted as two blockers in two categories. Every citation now names the component to actually go change.
Refresh bugs (all pre-existing)
reconcileSourceDeletionsdeleted the vault it was refreshing. It compared raw paths across two layouts, so every in-scope file read as "deleted in the org" — 974 nodes,status: success, exit 0. Reproduced by executing the shipped code: 8 of 8 files deleted. Now layout-agnostic, with a wholesale-deletion guard that refuses and explains.- The additive retrieve had never worked. It named an
--output-dirmodernsfrejects. Both explicit targets are refused; the accepted form names none. - A failed report pull was swallowed, leaving a vault byte-identical to a successful one. Now recorded on the manifest and printed.
- Report coverage asserted a confirmed zero it could never contradict —
retrievedwas counted after the usage fold drops those nodes, yetretrieveConfirmed: truewas stamped anyway, about an org with 4,296 reports.
⚠️ Behaviour change
safe is harder to reach on a coverage-degraded vault. Four more condition-firer families are attested, and a pre-0.2.5 vault routes safe → review until re-refreshed. Deliberate: an unretrieved family can hide a condition blocker. Re-run sfi refresh after upgrading.
New surface
salesforce-field-audit (26th skill) · salesforce-field-auditor + salesforce-field-refuter (the plugin's first subagents) · /sfi-field-audit · sfi.field_audit MCP prompt · field_360.rollups
209 tools · 26 skills · 5 slash commands · 2 subagents
Verification
9,895 tests, lint, all gates, CI on Node 20/22 + macOS. Real-org end-to-end through the MCP server: each fixed path returns blocking with a checkable citation, and a field with no dependencies still returns review — not blocking — so the tool still discriminates. All three refresh paths reconcile at identical counts.
Known limits
windows-build-test is skipped by repo config. Report coverage is honestly partial (1,959 of 4,296 retrieved, reported pending). The manifest edge tally is render-derived and under-counts; documented, not fixed.
📖 Can I delete this Salesforce field? — the method behind the release.
v0.2.4 — 209 tools · 9 new surfaces + 6 fixes
Nine new analysis surfaces + six correctness/honesty fixes. The read-only tool roster grows 203 → 209. Deterministic and offline throughout; every new surface ships its honesty boundaries and confidence tiers. No new dependency.
✨ Added
flow_bulkification_audit— flag DML / Get-Records inside Flow loops, plus filterless Get-Records (the Flow-side sibling ofgovernor_limit_risks).picklist_integrity_scan— org-wide picklist value-set integrity: declarative literals (VR / formula / Flow / WFR) referencing orphaned or inactive-only values.limit_headroom_report— offline metadata-vs-ceiling headroom, ranked worst-first (the Optimizer limit-report replacement).doc_coverage_report— documentation-gap meter overdescription+inlineHelpText, edge-degree weighted, lowest-coverage-first.permission_set_consolidation— redundant / duplicate / strict-subset permission-set merge candidates from declared grants.nonselective_soql— the first index-aware Apex static analysis: SOQLWHEREshapes that risk a full table scan at scale.automation_risk_report→mode: 'sprawl'— org-wide, per-object automation-density ranking ("where is sprawl worst first").review_change→checkAccessParity— flags added fields/objects that resolve to zero grants and would "ship for nobody".- Cited remediation on
interpret/synthesize_answer— dependency-ordered fix steps filled from the same grounded ids, stamped with the claim's own confidence (11 of 193 rules). Refuses counterfactual closure.
🔧 Fixed
safe_to_delete_field— polymorphic Activity / Task / Event shared-field write attribution (no more falsesafefrom a lostwritesTo).- PII classifier — protected-class +
securityClassificationrecognition. get_impact— soundness no longer over-claimscomplete: true.- Approval —
userHierarchyFieldrelated-user approver resolution. call_graph— phantom-node filtering (a localMapvar is no longer minted as a phantomApexClass).- Live staleness — millisecond-precision threshold fix, removing a ~1s false-positive drift window.
🎨 Also
- Full website SEO overhaul and refreshed brand assets (README hero + GitHub social-preview card), aligned to the canonical "Salesforce Org Intelligence for AI agents" positioning.
📦 npm: sf-intelligence@0.2.4 — published with provenance (keyless OIDC Trusted Publishing)
📄 Full detail: CHANGELOG.md
🔒 Read-only · offline · source-available · MIT + Commons Clause
v0.2.2 — reasoning concept model 94→142 concepts / 143→193 rules
[0.2.2] — 2026-07-22
Concept-model expansion + grow-forever funnel routing. The org-independent
Concept Model behindsfi.interpretgrows from 94 concepts / 143 rules to
142 concepts / 193 rules (+48 concepts / +50 rules) — every addition grounded
against an already-extracted metadata property or edge, each with a firing
interpret()seed proof, all curated general-Salesforce truth joined at query time
against the grounded vault slice (no org data in the model). The semantic funnel is
reworked sosfi.interpretis scored as the maximum over a base card plus one
independent per-concept card, removing the single-document saturation ceiling:
adding a reasoning concept no longer dilutes existing ones, so the model can grow
without bound while every prior query keeps its exact rank. Deterministic and offline
throughout — no LLM, no live org read, no new dependency, no package-weight change.
Added
- Reasoning model — eight new offline concepts (96 → 104 concepts, 145 → 154 rules).
A parallel discovery pass surfaced, and this change ships, eight additional
org-independent structural-implication concepts behindsfi.interpret, each grounded
on an already-extracted node/edge property with no new engine primitive and no
extraction change:concept:duplicate-rule-bypass-sharing-match— a duplicate rule whose
securityOptionisBypassSharingRulesruns its matching in system context,
comparing an incoming record against records the running user cannot see.concept:duplicate-rule-references-inactive-matching-rule— an active duplicate
rule that references a matching rule whoseruleStatusis notActiveperforms no
detection on that matcher: dead duplicate protection that silently lets duplicates save.concept:approval-process-final-lock-record-readonly— an approval process with
finalApprovalRecordLock/finalRejectionRecordLockleaves the record locked
read-only after it completes, so later user edits and automation updates fail until
it is unlocked.concept:record-type-inactive— a record type withactive=falseis not
assignable to new records; excluded from layout / business-process routing reasoning.concept:remote-site-setting-protocol-security-disabled— a remote site setting
withdisableProtocolSecurity=truepermits non-HTTPS outbound callouts to its host
(isActivegates whether it applies at all).concept:apex-intentional-system-mode-dml— Apex DML issued with an explicit
AccessLevel.SYSTEM_MODEargument deliberately opts out of the running user's object
CRUD and field-level security for that write. Surfaced as a review surface, honestly
NOT a proven defect (a system-context write is often correct); heuristic, from
tokenized source.concept:field-longtext-richtext-not-filterable— Long Text Area and Rich Text
(Html) fields cannot appear in a SOQLWHERE/ORDER BY/GROUP BY, a list-view
or report filter, and cannot be an external id or unique field.concept:dataraptor-field-security-unenforced— a DataRaptor
(OmniDataTransform) withfieldLevelSecurityEnabled=falsereads/writes SObject
fields without enforcing the running user's FLS.
Deterministic and offline throughout — citedgroundedIn, confidence-tiered claims, no
LLM, no live org read. Each ships with a firinginterpret()seed proof.
- Reasoning model — two new offline concepts (94 → 96 concepts, 143 → 145 rules).
sfi.interpretnow recognizes two additional org-independent structural-implication
concepts, each grounded against already-extracted metadata with no new engine primitive:concept:validation-rule-inactive— a validation rule whoseactiveflag is false
never evaluates its error-condition formula, so it can neither block a save nor surface
its message; it is excluded from save-order / save-failure reasoning and required-field
gate counts. Names the non-enforcing structural fact only (the formula is not evaluated
offline).concept:workflow-rule-inactive-dead— an inactive workflow rule is dead legacy
automation whose field updates, alerts, outbound messages, and time-dependent actions
never run; it is excluded from save-order and automation-impact counts. Does not claim a
Flow has replaced it (migration lineage is org-specific).
Deterministic and offline throughout — citedgroundedIn, confidence-tiered claims (all
declared), no LLM, no live org read. Each ships with a firinginterpret()proof and
natural-language funnel hooks so the concepts are reachable fromsfi.interpret's top-5.
- Reasoning model — five more offline concepts (104 → 109 concepts, 154 → 159 rules),
all NL-reachable via the grow-forever funnel. Each grounds on an already-extracted
property with no new engine primitive, ships a firinginterpret()seed proof, and gets
its own funnel card so it rankssfi.interprettop-5 for its natural questions without
diluting any existing concept:concept:field-classic-encrypted-text— a classic Encrypted Text field is masked
for users without "View Encrypted Data" and is not filterable/sortable/groupable, nor an
external id / unique / formula input (distinct from Shield Platform Encryption).concept:field-autonumber-system-assigned-readonly— an Auto Number field is
system-assigned at insert, read-only on every write path, null in before-save context,
and stored as a formatted string.concept:field-multiselect-picklist-storage-semantics— a multi-select picklist
stores selections as one semicolon-delimited string, so SOQL/reports must use
INCLUDES/EXCLUDES (not=/IN) and it cannot be a dependency controlling field.concept:permission-set-license-scoped— a permission set bound to a specific user
license is only assignable to users who hold that license.concept:session-based-permission-set-dormant— a session-based permission set
grants none of its permissions until it is session-activated; its grants are dormant
otherwise.
The last two were shipped-then-dropped earlier this cycle as funnel-losers (their query
space is shared with permission-set specialist tools); the grow-forever per-concept-card
funnel makes them independently reachable, so they are revived. Deterministic and offline
throughout — citedgroundedIn, confidence-tiered claims, no LLM, no live org read.
- Reasoning model — fourteen more offline concepts (109 → 123 concepts, 159 → 174
rules), all NL-reachable via the grow-forever funnel. A parallel design pass (one
grounding-verifier agent per candidate) produced these; each grounds on an
already-extracted property/edge with no new engine primitive, ships a firing
interpret()seed proof, and gets its own funnel card:field-restricted-global-value-set— a picklist bound to a global value set marked
restrictedis a closed vocabulary (out-of-set writes rejected; edits ripple to every
consumer).field-picklist-has-retired-values— a picklist retaining deactivated
(isActive=false) values keeps them on existing records though they are no longer
selectable.approval-process-inactive-dead— an inactive approval process cannot be submitted to.escalation-rule-time-deferred— an active escalation rule's actions fire from a
background time-based process, not synchronously on save.auto-response-rule-first-match-starvation— a catch-all auto-response entry ordered
before specific entries starves them (first-match).record-type-business-process-binding— a record type naming a business process
constrains the stage/status picklist for its records.apex-dynamic-reflective-surface— dynamic/reflective Apex (dynamic SOQL, describe
reflection) is an analysis blind spot and injection surface (heuristic).named-credential-merge-fields-injectable— a named credential allowing merge fields
in header/body can interpolate record/user data into outbound requests.connected-app-saml-sso-federation— a SAML-protocol connected app is an inbound SSO
federation trust surface.apex-fake-assertion-test— a test with tautological assertions inflates coverage
without verifying behavior (heuristic).entitlement-process-inactive— an inactive entitlement process applies no SLA
milestones to new entitlements.omnistudio-inactive-component-version— only the active version of a versioned
OmniStudio component is invoked at runtime.required-field-absent-from-all-layouts— a required field on no page layout cannot
be supplied through the UI, so UI inserts fail (absence-shaped).dataraptor-errors-ignored— a DataRaptor with Ignore Errors continues past
row-level failures rather than aborting.
Grounding for every concept was independently verified against the extractor source before
integration. Deterministic and offline — citedgroundedIn, confidence-tiered claims, no
LLM, no live org read.
- Reasoning model — nineteen more offline concepts (123 → 142 concepts, 174 → 193
rules), completing the concept-model build-out. A parallel mining pass (one agent per
extractor family) surfaced these by finding EMITTED node/edge properties that no concept
bound yet; each grounds on a verified-emitted property, ships a firinginterpret()seed
proof, and is NL-reachable via its grow-forever funnel card. By family:- Fields / objects —
object-deployment-status-in-development(In Development objects
hidden without Customize Application),object-autonumber-name-field(auto-numbered
record Name),global-value-set-has-inactive-value/standard-value-set-has-inactive-value
(deactivated values retained...
- Fields / objects —
v0.2.0 — capability expansion + correctness hardening
sf-intelligence 0.2.0 — the biggest release since 0.1, focused on new capability breadth, correctness, and protocol modernization. Offline, read-only, MCP-first knowledge base for one Salesforce org.
Added — 24 new tools (176 → 196 advertised)
- Agentforce / AI exposure audit (GenAI + Bot extraction), guest + Experience Cloud exposure modeling
review_changePR gate — cross-vault diff with SARIF output + a reusable GitHub Actionquery_graphpower-user graph query, permission-set what-ifs (what_if_assign/revoke_permset)- 8 new
live_*runtime tools (SetupAuditTrail history, security exposure, scheduled jobs, …) explain_error/explain_debug_log— paste an error/log, get the component that produced it- Field-level Flow dataflow lineage, generate-fleet-report, ~24 new component types
Fixed / hardened
- Profile-grant retrieve regression fixed; Profile now co-batched through retrieve splits with an explicit bare-profile disclosure
- canonicalJson crash class, case-variant dangling edges, N+1 query batching, muting permission-set subtraction
Router & protocol
- Trigger-gated de-crowd of the two what_if permission-set tools (+0.5pp top-5 advisory recall, zero losses, live-confirmed)
- MCP protocol:
readOnlyHint/openWorldHintannotations, structured output, prompts - Windows + macOS CI; npm Trusted Publishing (keyless OIDC, no token)
Full release gate green (60/60). Advertised roster = 196 distinct tools (200 registered incl. 4 back-compat aliases). See CHANGELOG.md for the complete [0.2.0] entry.
npm i sf-intelligence
v0.1.25 — description capture
Headline: the org's <description> text is now captured and queryable. Four
metadata types silently dropped their descriptions; now every type that carries
one keeps it, and a new missingDescription filter answers "which reports /
objects / permission sets are undocumented?" — previously an honest gap.
Upgrade note: the description is captured at extraction time, so run
sfi refreshonce on 0.1.25 to backfill descriptions into an existing vault.
Added
- Description capture across every metadata type that carries one. The
org's top-level<description>is now extracted intonode.properties.description
for the four generic metadata types that previously dropped it —Report,
Dashboard,ReportType, andPermissionSetGroup(extractReport,
extractDashboard,extractReportType,extractPermissionSetGroupin
enterprise-metadata.tsnow passextraProperties: ['description']). The
seven custom extractors (CustomObject,CustomField,PermissionSet,
Profile,Flow,ValidationRule,RecordType) plusCustomTaband
CustomApplicationalready captured it. The description renders as a paragraph
in the component markdown (unchanged renderer). Verified offline against a real
~1,300-object source tree: captured counts match the source's
files-with-<description>ground truth exactly for every generic type (Report
221, Dashboard 17, PermissionSetGroup 16, plus CustomField 1315, ValidationRule
306, PermissionSet 159, RecordType 86, CustomObject 75). Only the genuine
top-level<description>is captured — nested element-level descriptions (e.g.
a Flow decision's own<description>) are intentionally excluded, and Profiles
capture their single real top-level description with no fabrication. list_componentsdocumentation-coverage filter. NewmissingDescription
/hasDescriptionboolean flags answer "which reports / objects / permission
sets / validation rules have no description?" — previously an honest gap. Backed
by adescriptionPresence: 'present' | 'absent'narrow in the graph layer
(queries.ts) that folds key-absent, JSON-null, and empty-string all into one
honest "absent" bucket viacoalesce(json_extract_string(...,'$.description'),'').
The narrow is applied to both the page query and the authoritative
countNodesByTypetotal; the two flags are mutually exclusive (invalid-query
guard). Honesty caveat, disclosed in the tool description: for a type whose
source carries no<description>element at all (ListView,CustomPermission,
MutingPermissionSet,CustomMetadata),missingDescriptionmatches every
node — the answer means "no description in this metadata type", not "left blank".
v0.1.24 — assignment-data engine + honesty R4
Headline: assignment-data engine + router honesty R4 + experimental embeddings.
Three simultaneous workstreams: four new live tools close the "who holds X / who's in
Y" honest-gap family; the router's fourth honesty round adds write-evasion hardening,
forecast/authorship gaps, narrowed clarification, and show-me candidate coverage; and
an opt-in RRF hybrid embeddings layer sits behind a feature gate for early adopters.
Tool count 172 → 176.
Measured on the maintainer's two real-org suites (2,000 + 2,995 primary
questions, 12,352 turns, 0 route errors), 0.1.23 → 0.1.24: honesty rose on
both — declined-correctly 82.9 → 83.8% (2K) and 57.2 → 66.9% (3K, +9.7) —
while over-routing fell on both (2K 89 → 88; 3K 229 → 185, −44), reversing
the eagerness regression 0.1.23 introduced. The injection/write-evasion family
dropped from 8 leaks to 1 (a read-only report with execution skipped, unchanged
since 0.1.23); the curated write-execution set is 24/24 refused. Answer-recall
held (2K 82.9%, 3K 80.2%), recall@3 61.1, needs-live 76.9 → 78.1%. The recall
gain came from precision this round, not more eagerness — the opposite of the
0.1.23 trade.
Added
sfi.live_permset_holders— who HOLDS a permission set, permission set
group, or profile (kind: permissionSet | permissionSetGroup | profile | auto), answered from the live org. PSG-trap-aware: direct holders and
via-group holders (PermissionSetGroupComponent) are reported separately
with a dedupedeffectiveTotal, so the count is audit-grade instead of
confidently understated. True count first, expired assignments excluded and
disclosed, 500-row cap with byte-fit that never understates totals, keyset
paging (afterId/nextAfterId), optional per-profile buckets. This also
answers the name-by-name profile roster family.sfi.live_user_permsets— the REVERSE direction: what a named USER
holds. Direct permission sets vs via-PSG assignments (with expirations),
profile named;PermissionSet.IsOwnedByProfile = falseis pinned into every
assignment query so the system profile-owned row never masquerades as a
direct assignment. Pairs with vaultsfi.effective_permissionsfor a
dual-provenance answer (live = which grantors; vault = what they grant).sfi.live_group_members— who is IN a queue / public group right now:
users, nested groups (expanded at most ONE level, fail-closed and stamped
expansion: 'partial-one-level'), role-based members surfaced as ROLE
entries (never silently expanded), queuesupportedObjects("can this queue
own Case"), and a measuredvaultDeclaredMemberCountvs
liveDirectMemberCountdrift check.sfi.live_zombie_accounts— active users with login access but ZERO
permission-set/PSG assignments (single anti-join on
PermissionSet.IsOwnedByProfile = false; disclosed bounded client-diff
fallback when an org rejects the anti-join). Output states verbatim that a
"zombie" still holds everything its PROFILE grants. Optional
minDaysInactive/includeAllUserTypes. Dormancy-only questions stay on
sfi.live_inactive_users.- All four follow the live-plane contract: consent-gated (
sfi.live_consent/
SFI_LIVE_PLANE_ENABLED/liveEnabled), budgeted
(SFI_LIVE_QUERY_BUDGET, budget exhaustion is an honest error, never a
silent fallback), read-only SOQL,provenance: live_orgpoint-in-time
stamps. No user identifiers land in the vault — the counts-only facts pin
is untouched. sfi.coverage_reportassignmentDatasection — runtime assignment
data (User / PermissionSetAssignment / GroupMember) is reported as
"not in vault by design" (a runtime data object, not a retrieve gap),
naming the four live tools, current live-consent state, and the counts-only
facts snapshot presence/timestamp.sfi.health_checkcarries the same
block informationally — it never degrades status; a >30-day-old counts
snapshot earns an advisory only.
Changed
- Router retargets (same change as the tools — no contradictory gates):
permset-user-roster("which users have permission set X") flips from
honest-gap refusal tosfi.live_permset_holders;profile-user-roster
drops its partial-answer gap (the name-by-name roster is now built);
unassigned-permset-groupsandpermset-group-grantsflip partially to
sfi.live_permset_holders(per-PSG zero-holder check and PSG containment —
the enumerate-all-PSGs sweep and the 2-hop "which PSG grants custom
permission X" chain remain disclosed gaps); NEWqueue-group-member-roster
arm ("who's in the Support queue") routessfi.live_group_members; NEW
user-permset-holdingsarm ("what permission sets does Jane have") routes
sfi.live_user_permsets+sfi.effective_permissionsas an ordered
dual-provenance pair;empty-queues-groupskeeps the vault scan primary and
appendssfi.live_group_membersfor runtime verification. - The vault-side assignment disclosures (
object_access_audit,
who_can_access_object, and friends) now name the concrete live tools
("answerable via the live plane: …") instead of a generic "run the live org
plane" pointer. - Router R4 — honesty + candidate coverage:
Injection/write-evasion hardening (indirect re-delegation attempts and
tool-self-capability asks refused with a read-side alternative);
forecast/authorship honest-gaps (predictive "how will X change by…" and
authorship/attribution asks return honest gaps naming the nearest real reads);
narrow clarification re-introduced for genuine same-name collisions that
the R2b rebalance over-suppressed; show-me candidate coverage (visual/UI
render requests clarify to the relevant read tool rather than silently
mis-routing).
Experimental
- Embeddings hybrid (
SFI_EMBEDDINGS=1, off by default) — an opt-in
RRF hybrid layer that fuses the existing lexical TF-IDF candidates with a
locally cached neural sentence-embedding model (Xenova/all-MiniLM-L6-v2,
~23 MB, downloaded once on first use from HuggingFace Hub into
.sfi-embed-cache/). Affects candidate ranking only — the honesty/refusal
decision and theroute.toolsdeterministic plan are untouched.
Graceful lexical fallback when the model is absent or the embed fails;
allowRemoteModelsis disabled, so the funnel can never phone home at
query time. Requiresnpm i @huggingface/transformersin your project (not
bundled). Seedocs/configuration.md §Embeddingsfor full opt-in details.
v0.1.23 — 80% crossed
[0.1.23] — 2026-07-02
Headline: 80% crossed — candidate generation and honesty seams in the same
release. Two eval-driven rounds on 0.1.22's architecture. Measured on the
same 2,000-question real-org bank: answer-clean 77.6% → 80.5% raw
(81.0% relabeled), honesty over-routes down 97 → 89 with zero false
refusals, funnel-blind recall@8 57.3% → 69.8%, follow-ups with host
context 67.0%. Three-release trend on the same bank: 62.9 → 72.0 → 77.6 →
80.5%.
Added
- Runtime-analytics honest-gap arms — the refusal gate now recognizes the
unmodeled telemetry families and discloses the gap (naming the nearest real
reads) instead of routing: per-user login events/sessions/last-login
rosters, automation execution traces and aggregate run counts, run/failure
forensics ("the error message from the last time X failed"), CPU/heap
profiling, debug-log retrieval, SOQL execution plans, message delivery
counts and sent-message content, site/community click analytics,
record-level before/after field history, and record-access audit events
("who accessed…"). Precision-guarded: dormancy questions still reach
live_inactive_users, login IP ranges still reachprofile_security,
System.debugcode searches still reachsearch_apex_source, static
reference counts and save-order questions route unchanged. - Run-imperative refusal — "run the X flow against test data for me" /
"execute the batch job" is refused asrefused-write(executing automation
mutates the org) with a read-side alternative describing what the
executable WOULD do (explain_flow,scheduled_job_catalog,
what_happens_on_saveby target). Permission/hypothetical frames ("who can
run…", "what happens if I run…", "how do I run…") route normally. - Privilege-escalation injection arm — "sudo …" and grant-to-self asks
("give me full/admin access") landrefused-injection; READ delivery asks
("give me the FLS grant list…") are unaffected. permset-group-grantscapability gap — "which PSG grants the X custom
permission / the Y role" discloses that PermissionSetGroup composition is
not modeled instead of advisory-routing to permission tools that cannot
answer it; PSG→permission-set REFERENCE reads stay routed.- Gap detection before context continuation — a follow-up that is itself
gap-shaped (judgment, delivery/export, tool-self-capability,
deployment-status) never inheritsprevious.tool; it returns a
non-executablecontext-gap-followuproute with an honest disclosure.
Legitimate continuations ("is it safe to delete?", "what about on
Contact?") are unchanged, and a gap-shaped question WITHOUT context routes
exactly as before. - Business-user register corpus — the funnel utterance corpus grew a
non-technical phrasing band ("what business process does this flow
support", "the 10,000-foot view", "which automation is Salesforce going to
sunset", "a reference sheet for the business team", "who's been making the
most changes lately") plus matching synonym/idiom bridges (sunset→retire,
flips→transition, grade→health, "kicks in"→fires, "big picture"→overview).
Business-user recall on the additions-tuning set rose 60.9% → 76.1% with
zero regressions. component-typeintent — "is a flow or a trigger?", "…has
'Trigger' in the name but is it actually a test class?", "what type is
that?" now routes resolve-first with both family explainers; a same-name
cross-type collision is treated as the ANSWER (resolve enumerates it), not
an ambiguity block. Type-confusion trap family: 52.2% → 91.3%.- Type-confusion premise disclosure — when a type-scoped resolve finds
nothing ("the X permission set") the premise check now retries UNSCOPED
before declaring nonexistence: a strong match under a different family
discloses "TYPE CHECK: X exists as , not as a " and
keeps routing on the component that actually exists. Pure ghosts still get
the existence PREMISE CHECK and still never advisory-route. - Genuine-tie clarifications restored (post-P4 rebalance) — quoted bare
labels ("the 'Status' field") now reach entity resolution; a question that
itself asserts a same-name family ("three different things called Status")
always clarifies; a bare label the resolver silently picked one of several
identical same-name parents for ("the 'Concentration' field" ×3 parents)
clarifies; and a compound vault+live ask ("what breaks AND is it actually
running in production") returns a two-plane tool-choice clarification.
Additions-tuning clarify 3/13 → 9/13; the P4 junk-tie suppression is
unchanged (no previously-clean answer question started blocking). - CI self-recall gate — new test (
funnel-self-recall.test.ts): every
candidate-eligible tool must retrieve itself in the pure-funnel top-8 for
≥70% of its own utterances, so a tool invisible to the funnel is a CI
failure forever (measured floor at landing: 77.8%).
Fixed
- Write-gate impact carve-out — "can you deactivate X safely? I need to
know what depends on it" is a what-if impact ask, not a mutation
instruction:safely?and explicit dependency/breakage questions now
excuse the write-imperative refusal. Bare imperatives still refuse. - Shield event-monitoring log retrieval — "show me the event monitoring
log from last Tuesday" now lands the same runtime-telemetry honest-gap as
debug-log retrieval (additions-tuning honest-gap 16/16). website/recalibrate.mjsstale-count bug — the capabilities.html
rewrite rules were spacing/markup-sensitive and silently no-opped when the
page copy reflowed (the shipped stale "171 tools"). Rules are now
whitespace- and markup-tolerant, and a post-rewrite tripwire warns on any
surviving tool-count string that disagrees with the registry.