You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
title and websiteUrl on the MCP Registry record.packages/cli/server.json — the manifest the release workflow publishes to the official MCP Registry on every v* tag — carried name, description, status, repository, version and packages, and nothing else. The published record had no human-readable name beyond the reverse-DNS id io.github.PranavNagrecha/sf-intelligence, and the GitHub repo as its only link; the documentation site appeared nowhere in it. The record now sets a title and websiteUrl (https://sfi.auditforce.cloud). 0.3.2 is the first release that PUBLISHES them: the fields were added 70 minutes after the v0.3.1 tag 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 mcp wrote its "run sfi 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 exposes sfi.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 init now requires--target-org when 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's path-portable. The rule it encodes: the number of correct spellings is one. toPosixPath (unconditional) and toRelativePosix (host-gated) are deliberately kept distinct — the vault's sourceTreeHash depends 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 that rmdir without 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 / --exclude filters. Genuinely POSIX-only fixtures (a #!/bin/sh script, a 0600 file mode, a COMSPEC-stubbed win32 simulation) are describe.skipIf(process.platform === 'win32') in their own source, where a reader can see them; the rest now derive their expectations from node:path instead of hardcoding a POSIX rendering.
New pnpm check:portability gate (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 of tools/list — and where they disagree, a host that validates arguments before sending them refuses calls the handler would have served. Three were flagged on the required axis: sfi.get_component advertised id as required while the already-advertised componentId alias satisfies it through the preprocess, sfi.explain_error advertised errorText while error / message / errorMessage / text each satisfy it, and sfi.explain_debug_log advertised logText while debugLog / log / text / content do. 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 with fieldId at the validator) on sfi.explain_field and sfi.field_access_audit, eventApiName on sfi.event_subscribers, staticOnly on sfi.unused_fields_deep, and the object selectors on sfi.layout_assignments and sfi.automation_collisions. sfi.automation_collisions was the worst of them: its advertised schema paired additionalProperties: false with required: ['object'], so componentId / objectApiName / objectId were not merely invisible — the advertisement declared them INVALID. Seven required lists shrank in all, not three, because advertising an alias is what makes the old key omittable: required: ['fieldId'] came off sfi.explain_field and sfi.field_access_audit, required: ['componentId'] off sfi.layout_assignments, required: ['object'] off sfi.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 Schema required cannot 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 advertised objectId, which the ADR-007 id-naming gate reads as fresh drift — grandfathered in response-consistency-baseline.json beside the 21 sibling tools that already carried it.
retrieveConfirmed was 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. stampFamilyEpochs carries retrievedAt and epoch forward across a --no-pull rebuild and silently dropped retrieveConfirmed alongside 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 a retrieveConfirmed this pass set itself, an error it found, or a pending a decorator forced. Delivered in the same squash:
pending meant 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 wrote pending: true beside a non-zero retrieved — a family with 388 items actually retrieved read as if nothing had run. A separate capped state now carries that case, with its own bucket in sfi.coverage_report. Downstream behaviour is deliberately unchanged: a capped family is still excluded from covered, still folded into missingCoverage, and every absence caveat still fires — only the REASON is now legible. A --no-pull or scoped refresh with no cap evidence of its own falls back to the previous manifest's reportsCap block rather than regressing the row to the fold-erased default.
ConditionalContext was required coverage it can never satisfy. It is a synthetic node the extractor mints while parsing a firer's condition — never a family sf project retrieve pulls — so buildCoverageEntries never writes it a coverage row. Naming it in VALUE_LITERAL_READER_COVERAGE made that list permanently unsatisfiable, poisoning missingCoverage for its two consumers, sfi.value_change_audit and sfi.what_if_remove_picklist_value: every answer hedged against a gap no refresh could ever close. Replaced by the firer families that actually produce firesWhen edges — ApprovalProcess, AutoResponseRule, AssignmentRule, EscalationRule join the WorkflowRule / ValidationRule / Flow already listed. Both consumer fixtures had hand-copied the list and invented a ConditionalContext coverage row no real vault has, which is exactly how it went unnoticed; they now import the shared constant.
sfi.find_component_usages counted a component's own declaration as evidence something uses it. The grep supplement matched a class's own class Foo line exactly as a caller's reference would, so a component with zero graph referrers and one grep hit — its own declaration — reported hasStaticEvidence: 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 before matchCount and hasStaticEvidence are computed, counted in a new selfMatchesExcluded, and named in boundaries[] so "grep ran and found only its own declaration" cannot be read as "grep ran and found nothing".
sfi.who_can_access_object shipped half a page and called it whole. The handler sized its page to limit but not to the response byte budget, so the envelope's blind tail-truncation cut granters out from under the already-computed hasMore / truncated: 218 real rows delivered as 109, with hasMore and truncated both false, so nothing told the caller there was more to page for. The complete data payload is now fit to the budget by binary search before the envelope sees it, so hasMore / truncated / offset describe the rows actually shipped. Sharing-rule rows also gained sourceRuleId: via names the rule TYPE, not the rule, so two different rules of the same type sharing with the same principal collided on the granterId|via key the tool documents as independently addressable — measured on a real vault at 218 rows carrying only 217 distinct granterId|via keys, the collision coming from two distinct SharingRule components of the same type.
sfi.unused_fields_deep answered for objects that do not exist. An unresolvable object scope returned {fields: [], totalCount: 0} — an UNCHECKED zero wearing a CHECKED zero's clothes, indistinguishable from "this object has no unused fields". It now routes through the same resolveExistingObjectScope its sibling object-scoped tools (flow_bulkification_audit, flow_fault_audit, the synthesis reports) already use: an unknown name is refused as invalid-query, a real object typed in the wrong case still answers, and a scoped call echoes appliedScope so it can never be read as org-wide.
sfi.interpret's oversize error prescribed parameters it does not have. The generic remedy offered "(filter, pagination, fewer hops)"; this tool's only narrowing knobs are ruleIds and concepts, which the shared knob-name regex cannot infer from the schema. The error now names them plus a concrete example call.
CONCEPT_BLOCK_HARD_MAX_BYTES was not hard. The halving loop stopped at claimCap > 1, so a single claim larger than the 6,000-byte ceiling — measured at 7,819 B on a real object — fell through the loop body untouched and was returned oversized, in the concept-reasoning block sfi.get_component, sfi.field_360, sfi.explain_apex_method and sfi.what_happens_on_save all embed. The loop now reaches zero claims, which reuses the existing claimsTruncated disclosure rather than cutting a claim without saying so.
A class implementing a namespaced interface still read as dead.find_dead_code and method_reachability treated a dotted superclass and the Callable interface as unproven-registration signals, but not a dotted implements entry — so an Auth.RegistrationHandler, which an AuthProvider record names by type and the platform instantiates without minting any edge, was reported as a dead-code candidate. Added as a third namespaced-interface unproven-registration kind in both the TypeScript predicate and the SQL CTE a drift test pins them together with, and written into the verbatim disclosure and the uncertain verdict's reasoning. It maps to uncertain: "not dead", never "proven reachable". The predicate is knowingly wide — a locally-declared Outer.InnerInterface matches the same dotted test — and the comment says so and names the narrowing to apply if definitely_dead collapses.
computeFilteredPhaseOmission deleted from sfi.what_happens_on_save: a second hand-rolled copy of a rule computePhasesOmitted already applies correctly when handed a single phase's steps.
sfi watch --auto-refresh now says in its --help text and its JSDoc that it CONTACTS THE ORG (sf project retrieve) from a background daemon, unprompted, and names the command it actually runs (sfi refresh --incremental --incremental-graph, where the old text said only --incremental). Behaviour untouched — gating unprompted network traffic is the operator's call, but it should not be a surprise.
Four roster tool descriptions (coverage_report, unused_fields_deep, method_reachability, find_dead_code) restated to match the code above, and the admin-documentation-generators skill's worked example, whose section counts summed to 52 while the prose claimed 53 — the object itself is the missing +1, and componentIds leads with it.
Sixteen more advertised-vs-enforced disagreements, across twelve tools — three of them hiding capability no host could request. With these closed, 25 of the original 27 parity entries are gone and 2 remain.
sfi.search_flow_metadata advertised required: ['query'] while the validator asks for query OR summarize: true. Its status-summary mode — count flows by <status>, no query — was therefore unrequestable from a schema-driven host; summarize and triggerObject are now advertised. triggerObject was accepted-and-unadvertised on sfi.list_components too, along with status and recordTriggered.
sfi.lightning_pages accepted SEVEN unadvertised keys. Three are object aliases (object / objectApiName / objectId) that duplicate a scope a CustomObject:componentId already reached; the other four are the whole profile* family (profile / profileApiName / profileId / profileName), which is the only way to narrow page assignments to one profile and had no advertised equivalent at all. Its required: ['componentId'] came off so the aliases are usable.
sfi.list_view_sharing accepted EIGHT — the same three object aliases, plus its entire filter surface: sharedToId (with sharedTo and groupId as accepted spellings of it), sharedWithRoleApiName, and nameContains. Those decide WHICH list views come back, so a caller who could not name them silently received the unfiltered answer — the handler echoes the applied filter in appliedScope precisely because a silently-stripped filter is the failure it was built against.
sfi.why_cant_user_see_record advertised required: ['componentId', 'userContext'] while the validator takes componentId OR objectApiName, and never advertised objectApiName — the exact key whose Zod-stripping used to hard-fail the tool with componentId: Required. required is now ['userContext'] alone.
sfi.live_report_usage, sfi.live_folder_access and sfi.live_email_template_usage validate with .strict() — an unrecognised key is hard-REFUSED, so a filter passed under a name the tool does not accept cannot be stripped and answered with the org-wide dump — yet they advertised no additionalProperties, so a host could not know a typo would be rejected rather than ignored. They now say so, and advertise the narrowing they already honoured: nameContains on all three, folderName on the two that have folders.
Rounding out: the optional class scope (componentId / classApiName / apiName) on sfi.governor_limit_risks, the object aliases (objectApiName / componentId) on sfi.generate_sharing_summary, five further class-selector spellings (className, apiName, componentId, classId, apexClassId) on sfi.apex_test_coverage — whose per-class mode was already reachable through the advertised classApiName / apexClass, so this is alias coverage, not a mode being unlocked — and objectApiName on the hidden back-compat alias sfi.cdc_subscribers.
The two entries left are a decision, not a backlog. sfi.integration_map and sfi.tech_debt_score accept object-scope keys ONLY so the handler can REFUSE them — "integration_map returns the ORG-WIDE integration topology; it cannot scope to a single object" — and point at the tools that ARE object-scoped, instead of silently returning a whole-org answer that was byte-identical for Contact vs Opportunity vs bare. Advertising those keys would tell a host they are valid scopes. Cost recorded: four new non-canonical id names (sharedToId, groupId, classId, apexClassId) entered the ADR-007 grandfathered baseline, along with rows for sfi.lightning_pages under profileId and sfi.lightning_pages / sfi.list_view_sharing under objectId. Advertising did not create that drift; it made it visible.
sfi.find_hardcoded_values_anywhere could not see a hardcoded custom-object id at all — and said the opposite in its own disclosure. The ID search ran /\b0[0-9a-zA-Z]{14}([0-9a-zA-Z]{3})?\b/ over the formula, validation-rule, workflow-rule, restriction-rule and custom-label corpora. That leading 0 is a check that cannot fire for 12 of the 36 key prefixes this codebase recognizes — Case 500, Campaign 701, Contract 800, Order 801, OrderItem 300 / 802, and the entire custom-object range a00–a05 — and the \b made it strict rather than lenient: every character of an id is a word character, so the 0 could only ever anchor at the START of the token. A hardcoded custom-object id in a validation rule is close to the single most likely thing this tool is asked to find, and it was structurally invisible; the tool then reported the resulting zero as a completed scan. Every id-category search also carried a boundaries[] line promising the results were "filtered to a known-key-prefix allowlist (~40 prefixes)" and that "arbitrary 15-character alphanumeric strings outside the allowlist are not returned" — no such filter existed anywhere in the file, so the disclosure was false in both directions at once: it under-reported real ids it could not match, and it returned junk like 0zzzzzzzzzzzzzz that the promised allowlist would have rejected. Root cause is the usual one, a second copy: packages/patterns already had the correct rule for the Apex corpus — shape first, then a key-prefix allowlist — but KNOWN_KEY_PREFIXES was private, so the MCP tool grew its own version that disagreed on both axes. The set and a new isKnownSalesforceIdLiteral predicate are now exported and shared, the regex matches shape only (15 or 18 alphanumerics) with the allowlist deciding what is an id, and the disclosure derives its prefix count from the set instead of restating a number. Ids on Case, Campaign, Contract, Order, OrderItem and the custom-object range are now matchable for the first time; 0-prefixed real ids are untouched — Account and ApexClass still match, so the control held — and an id-shaped string outside the allowlist is now rejected.
The Claude Code plugin marketplace advertised 0.2.5 — through the whole of 0.3.0 AND 0.3.1..claude-plugin/marketplace.json read 0.2.5 in BOTH metadata.version and plugins[0].version at the v0.3.0 tag and again, untouched, at v0.3.1, while packages/cli/package.json, packages/cli/server.json and .claude-plugin/plugin.json each read the release they shipped. That is the version this repo advertises to a plugin marketplace, so the drift faced outward and nothing reported it: scripts/check-version-consistency.mjs did not read .claude-plugin/ at all — neither file. plugin.json was correct only because someone bumped it by hand each release, which is not a mechanism. The gate now reads both and fails on a drift in either: plugin.json's version, and marketplace.json's metadata.version plus its first three plugins[] slots — one plugin exists today, so that is total coverage.
Two recognizers reported things nobody wrote — the same mistake at two scales: match a short pattern with no boundary, then present the match as a fact.
The formula tokenizer harvested digits out of field names.NUMERIC_LITERAL was /\d+(?:\.\d+)?/g with no identifier guard, and a Salesforce API name may legally contain digits, so the digits INSIDE a field name became literals of the formula: sfi.explain_formula reported IF(ISBLANK(Score2024__c), 0, Score2024__c) as containing three literals — 2024, 0, 2024 — where the formula has exactly one constant, and IF(Q1_Total__c > 100, 1, 0) as 1, 100, 1, 0, the leading 1 harvested out of Q1_Total__c rather than typed by the author. A caller reading a formula's constants was shown numbers that are not in it. The comment above the regex asserted that "the invalid-identifier check above already rejected those mixes"; it does not and cannot, because a digit inside an otherwise well-formed API name is legal Salesforce and there is nothing for that check to reject. The comment WAS the guard. Replaced with lookarounds that hold; real literals (2, 1.5, 7) are untouched, and the boundary change is confined to the literal census — no edge or identifier extraction reads it.
PII detection classified hardware as personal data. Name tokens were tested with apiNameLower.includes(token), which is right for a distinctive token like postalcode and wrong for a short one that is a common English substring: Seating_Capacity__c, Electricity_Usage__c, Velocity__c, Publicity_Flag__c and Toxicity_Level__c (city), Headphone_Model__c and Microphone_Count__c (phone) and Streetlight_Count__c (street) all came back as "name suggests contact or personal-name data" — from sfi.pii_inventory, sfi.field_access_audit, sfi.field_360, sfi.ai_exposure_report, sfi.history_tracking_gaps, sfi.safe_to_delete_field and sfi.unused_fields_deep, which run this recognizer at query time, and from sfi.guest_exposure_report, which inherits the same verdicts through pii_inventory's collector. A field-access audit that calls an asset's headphone model personal data teaches the reader to skim the report. The file already carried the remedy, applied to exactly one token — hasRaceSegment matches race whole-word-only so Grace and Trace do not fire — now generalized into WHOLE_WORD_ONLY_TOKENS, so the next ambiguous token is one line rather than another bespoke branch. telephone is carried as its own token, because whole-word phone alone would drop Telephone__c, whose single word segment is not phone. Verified in both directions: eight false positives cleared, and Mailing_City__c, BillingCity, Home_Phone__c, CellPhone, Telephone__c, Street_Address__c and MailingStreet still classify.
Both recognizers run on demand, so no re-refresh is needed to get the corrected answers.
A refresh that modelled zero components reported success. With no per-file failures the status read success, the CLI printed "Refresh success", sfi status called the vault "locally consistent", and sfi quickstart moved the user on to asking questions — while every answer came back empty. Only sfi doctor disagreed. The realistic way in is a DX repo whose metadata sits in force-app/ while the vault reads org-kb/source/. A zero-component build is now partial, exits non-zero, and says why. A legitimately empty scoped refresh is unaffected — the discriminator is components modelled, not files walked.
sfi init silently bound a vault to whatever org happened to be the machine default.stdin.isTTY is false for every script and every MCP host, so the non-interactive path was the common path, and it adopted getDefaultOrgAlias() with no confirmation. A vault is bound to one org permanently and every later answer — live reads included — attaches to that binding, so a user with a sandbox and a production org authenticated could vault the wrong one and only find out when an answer was wrong. It now fails closed, naming the detected default so the remedy is one paste. This contradicted the product's own rule, stated two hundred lines away in sfi mcp: "the product never guesses which of your orgs to query."
VS Code + GitHub Copilot was undocumented and the shipped config did not work there. VS Code's mcp.json uses a top-level servers key, not mcpServers; pasting the documented Claude block into .vscode/mcp.json parses successfully and registers zero servers, with no error dialog and nothing in MCP: List Servers. README and installation guide now ship the correct block for each host.
Every documented MCP registration omitted --vault. The server resolves ./org-kb against its own working directory, which the host chooses — Claude Desktop and user-scope VS Code servers never run inside the user's Salesforce project, so a correctly built vault was not found. All shipped config blocks now pass an absolute vault path.
sfi mcp reported the raw manifest does not exist: <path> internal error when a vault was initialised but never refreshed. That state now reports as setup mode with sfi refresh named as the next step.
Documentation named only macOS paths and POSIX shell syntax. Added the Windows Claude Desktop config path, the schtasks equivalent of the cron refresh schedule, and Windows/GUI-launch rows to the installation troubleshooting table (including the spawn ENOENT PATH failure, which affects Dock-launched macOS apps as well).
docs/guides/installation.md §3 told users to verify with a degraded "no vault" health_check envelope that was unreachable — the process exited before any tool could be called. The step now describes setup mode, which makes the documented behaviour real.
EmailTemplates were silently dropped from the vault on Windows.deriveEmailTemplateFolderAndName split a native path on '/', guarded by a comment claiming "Windows paths are normalized at the loader boundary" — there is no such boundary. Every template returned malformed-input, the vault held zero EmailTemplate nodes, and the refresh still reported partial and carried on. deriveComponentApiName and deriveParentApiName had the same defect via basename/dirname, which are bound to the host separator.
sfi review-change could pass a deploy gate it had not parsed.relativeSegments split on a hardcoded sep, but review-change passes paths from git diff, which emits forward slashes on every platform. On Windows each path became one segment, every changed file failed to dispatch to a type, findings were dropped, and the gate printed "Nothing to gate" and exited 0 with overallVerdict: 'safe' — a field deletion with 40 dependents would sail through CI.
Every MCP response leaked the operator's username on Windows. The ~ redaction tested startsWith(${home}/), which never matches a C:\Users\… path, so vaultState.vaultPath carried the real home path over both stdio and HTTP. Now also case-insensitive on Windows, and the vault-is-home case collapses to ~ instead of the absolute path.
sfi.get_component returned an absolute native path in the path field on Windows, where its contract promises a vault-relative one, and disagreed with the same field from component-doc-fallback.
Two tests passed vacuously and now assert something real: serve-http's username-redaction check seeded its vault in tmpdir(), which is outside $HOME on macOS/Linux, so there was never anything to redact; and the consent-file 0600 check used a bare return on Windows, which vitest reports as passed rather than skipped.
The vault-lock error told Windows users something untrue. It stated that sfi refresh "handles this AUTOMATICALLY … no restart needed", which holds only on POSIX: the refresh rebuilds into a side file and renames it over the live database, and Windows will not allow that rename while a connected MCP server holds the file open. A Windows user hitting the lock was sent looking for a bug that was not there. The remedy is now derived from the platform — Windows is told to close the client, refresh, and reopen — and a drift test asserts both branches.
Documented the limitation in docs/guides/mcp-hosts.md and the installation troubleshooting table rather than leaving it to be discovered. Fixing the underlying swap needs generation-named database files, which is a larger change than this release should carry.