{"id":51325625,"url":"https://github.com/manderse21/claude-powershell-lsp","last_synced_at":"2026-07-01T18:01:27.013Z","repository":{"id":362992795,"uuid":"1261222579","full_name":"manderse21/claude-powershell-lsp","owner":"manderse21","description":"Real-time PowerShell diagnostics and PSScriptAnalyzer fixes inside Claude Code, as it edits your .ps1/.psm1/.psd1 -- powered by PowerShell Editor Services.","archived":false,"fork":false,"pushed_at":"2026-06-28T16:07:28.000Z","size":764,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-28T16:23:18.209Z","etag":null,"topics":["claude-code","claude-code-plugin","developer-tools","diagnostics","linter","lsp","powershell","powershell-editor-services","psscriptanalyzer","static-analysis"],"latest_commit_sha":null,"homepage":"https://github.com/manderse21/claude-powershell-lsp#quick-start","language":"PowerShell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/manderse21.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-06T11:59:20.000Z","updated_at":"2026-06-28T16:01:52.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/manderse21/claude-powershell-lsp","commit_stats":null,"previous_names":["manderse21/claude-powershell-lsp"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/manderse21/claude-powershell-lsp","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manderse21%2Fclaude-powershell-lsp","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manderse21%2Fclaude-powershell-lsp/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manderse21%2Fclaude-powershell-lsp/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manderse21%2Fclaude-powershell-lsp/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/manderse21","download_url":"https://codeload.github.com/manderse21/claude-powershell-lsp/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manderse21%2Fclaude-powershell-lsp/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35017091,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["claude-code","claude-code-plugin","developer-tools","diagnostics","linter","lsp","powershell","powershell-editor-services","psscriptanalyzer","static-analysis"],"created_at":"2026-07-01T18:01:02.230Z","updated_at":"2026-07-01T18:01:26.922Z","avatar_url":"https://github.com/manderse21.png","language":"PowerShell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# PowerShell diagnostics for Claude Code\n\n[![CI](https://github.com/manderse21/claude-powershell-lsp/actions/workflows/powershell-lsp-ci.yml/badge.svg)](https://github.com/manderse21/claude-powershell-lsp/actions/workflows/powershell-lsp-ci.yml)\n[![version](https://img.shields.io/github/v/tag/manderse21/claude-powershell-lsp?sort=semver\u0026label=version\u0026color=blue)](https://github.com/manderse21/claude-powershell-lsp/tags)\n[![license: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue)](./LICENSE)\n[![SBOM: CycloneDX](https://img.shields.io/badge/SBOM-CycloneDX-brightgreen)](./TRUST.md#supply-chain-artifacts-sbom--build-provenance)\n[![corpus false-positive rate: 0%](https://img.shields.io/badge/corpus%20false--positive%20rate-0%25-brightgreen)](#diagnostic-correctness-corpus)\n[![release signing: Sigstore](https://img.shields.io/badge/release%20signing-Sigstore%20keyless-brightgreen)](./TRUST.md#signing-posture)\n\nAs Claude edits a `.ps1`, `.psm1`, or `.psd1`, this plugin runs real PowerShell\nEditor Services + PSScriptAnalyzer over that file and feeds the result -- syntax\nerrors and lint findings, with fix suggestions -- straight back into Claude's\ncontext, so a mistake gets caught and corrected in the same turn. It is language\ntooling, not project tooling: near-zero always-on token cost, a language server\nspawns only when a PowerShell file is open, and one warm process serves the whole\nsession, so each edit pays a fast pipe round-trip instead of a cold start.\n\n![demo: Claude writes an unapproved-verb function, the diagnostic appears inline, Claude fixes it next turn](docs/media/demo.gif)\n\n**See it catch something.** Ask Claude to write:\n\n```powershell\nfunction Frobnicate-Thing { Get-Process }\n```\n\nand the PostToolUse hook returns, right in Claude's context:\n\n\u003e The cmdlet 'Frobnicate-Thing' uses an unapproved verb. (PSUseApprovedVerbs)\n\nClaude sees its own mistake and corrects it without you switching tools.\n\n**Install in under a minute.** Requires `pwsh` (PowerShell 7+) on your PATH; then,\nin Claude Code:\n\n```text\n/plugin marketplace add manderse21/claude-powershell-lsp\n/plugin install powershell-lsp@claude-powershell-lsp\n/plugin enable powershell-lsp\n```\n\nStart a new session and you are running. The full prerequisites, the self-bootstrap\nsequence, and the preflight doctor are in [Quick start](#quick-start) below.\n\n\u003e **What works today vs. what is coming.** The per-file diagnostic loop above is live\n\u003e on every supported host right now. Hover, go-to-definition, find-references, and\n\u003e workspace-wide analysis are on the roadmap -- native LSP serve is gated on an upstream\n\u003e Claude Code initialization handshake, not on this plugin. Details:\n\u003e [Why a hook, not native registration](#why-a-hook-not-native-lspjson-registration).\n\n## Prerequisites\n\nCheck these before you start; the [Quick start](#quick-start) below runs them in order.\n\n- [ ] **PowerShell 7+ (`pwsh`) on your PATH.** As of 1.1.1 the plugin's hooks launch\n  under `pwsh`; Windows PowerShell 5.1 alone cannot bootstrap them. Check with\n  `pwsh -v`; if it is missing, step 1 of the Quick start installs it.\n- [ ] **Internet access on the first enabled session.** PowerShell Editor Services\n  (PSES) and PSScriptAnalyzer are downloaded on first use, not vendored (see\n  [Pinned versions](#pinned-versions) for the exact pins). The download is idempotent\n  and marker-gated -- it runs once and no-ops every session after. Offline or behind a\n  proxy, the first run surfaces an honest `unavailable` banner instead of failing\n  silently (see [Diagnostics status](#diagnostics-status)).\n- [ ] **On managed / locked-down Windows,** a security control (WDAC / AppLocker /\n  ExecutionPolicy / Constrained Language Mode) can block a downloaded component; it\n  then reads as `unavailable` rather than crashing. See [Troubleshooting](#troubleshooting).\n\nWindows PowerShell 5.1 can still serve as the PSES *child host* (set `ps_host` to\n`powershell`); it simply cannot launch the hooks themselves. See\n[Platform support](#platform-support).\n\n## Quick start\n\nCopy-paste, top to bottom:\n\n```text\n# 1. Prerequisite (run in a terminal) -- skip if `pwsh -v` already works:\nwinget install Microsoft.PowerShell\n\n# 2. In Claude Code -- add the marketplace, install, then enable the plugin:\n/plugin marketplace add manderse21/claude-powershell-lsp\n/plugin install powershell-lsp@claude-powershell-lsp\n/plugin enable powershell-lsp\n\n# 3. Start a new session (or run /reload-plugins) so the hooks load and the first\n#    SessionStart bootstraps PSES + the warm daemon.\n\n# 4. Confirm it is healthy before you rely on it -- run the preflight DOCTOR from\n#    inside the enabled session (so it can see the plugin data dir):\npwsh -File \"$env:CLAUDE_PLUGIN_ROOT/scripts/doctor.ps1\"\n#    All PASS (benign UNKNOWNs are fine) -\u003e ready. A FAIL names the exact fix.\n\n# 5. See it catch something: ask Claude to edit a .ps1 -- e.g. write\n#    `function Frobnicate-Thing { Get-Process }` -- and the PostToolUse hook returns\n#    \"The cmdlet 'Frobnicate-Thing' uses an unapproved verb.\" (PSUseApprovedVerbs).\n```\n\nThe machinery self-bootstraps, so the sequence above is the whole job -- from install to a\nreal caught diagnostic in about five minutes. A few of its steps are deliberate, documented\nhere rather than removed:\n\n- **`/plugin enable` stays an explicit step.** The plugin ships disabled by default\n  (`defaultEnabled: false`) because it downloads a bundle and spawns a language server,\n  so enabling it is a conscious opt-in.\n- **The new session / reload is required** -- Claude Code loads plugin hooks at session\n  start, so enabling alone does not load them.\n- **The first enabled session does the rest itself.** Its `SessionStart` hook downloads\n  PSES and vendors PSScriptAnalyzer (both idempotent and marker-gated), then launches\n  one warm daemon for the session. The first edit may briefly read `incomplete` while\n  PSES finishes starting, then settles on the next edit (see\n  [Diagnostics status](#diagnostics-status)).\n- **Run the doctor first (step 4).** It turns the worst onboarding failure -- enabled but a\n  prerequisite is missing, so diagnostics silently do nothing -- into a named, actionable\n  fix-list, and it confirms the warm daemon is actually answering before you trust a silent\n  result as \"analyzed, clean\". It is **report-only** (it never downloads, repairs, or starts\n  anything); fuller details under [the preflight doctor](#preflight-self-check-the-doctor).\n\n## Configuration\n\nSet these via the `/plugin` config UI for `powershell-lsp`, or leave the defaults.\n\n| Key                | Default  | Meaning                                                                              |\n|--------------------|----------|--------------------------------------------------------------------------------------|\n| `ps_host`          | `pwsh`   | Host executable: `pwsh` (PowerShell 7+, recommended/tested) or `powershell` (Win 5.1) |\n| `severityThreshold`| `Hint`   | Least-severe level to report: `Error` \u003e `Warning` \u003e `Information` \u003e `Hint`            |\n| `ruleInclude`      | _(empty)_| Comma-separated PSScriptAnalyzer rule codes to report exclusively; empty = all        |\n| `ruleExclude`      | _(empty)_| Comma-separated rule codes to suppress (e.g. `PSAvoidUsingWriteHost`)                  |\n| `timeoutMs`        | `5000`   | Total hard cap (ms) before the PostToolUse client degrades to log-only                 |\n| `debounceMs`       | `150`    | Edits landing within this window (ms) fold into one analysis pass                      |\n| `keepLastN`        | `10`     | Newest rolling log files kept per family (swept at SessionStart)                       |\n| `idleTtlMin`       | `30`     | Daemon self-terminates after this many minutes with no diagnostics request            |\n| `perFileCap`       | `20`     | Max diagnostics reported per file; the rest collapse into an `... and N more` line; `0` = no cap |\n| `enableStats`      | `false`  | Append one JSONL timing line per analyzed edit to `logs/stats.jsonl` (rotating, ~5 MB); observe-only, never changes output. View with `scripts/show-stats.ps1`. `0`/`off` disable |\n| `settingsPath`     | _(empty)_| Absolute path to a `PSScriptAnalyzerSettings.psd1` to honor, overriding auto-discovery; a relative value is ignored; empty = auto-discover (nearest file walked up to the project root) |\n| `scopeToEdit`      | `true`   | Scope surfaced diagnostics to the lines the edit touched (plus `editContextLines`); fails open to whole-file when the range is indeterminate. `0`/`off` report whole-file |\n| `editContextLines` | `0`      | Extra context lines kept above and below the touched range when `scopeToEdit` is on; the edit's patch already includes a few, so the default is `0` |\n| `formatOnEdit`     | `off`    | When `suggest`, after an edit the warm daemon runs `Invoke-Formatter` on the file (honoring the repo's `PSScriptAnalyzerSettings.psd1`) and surfaces the formatted result as a **suggestion** -- a unified diff -- via the same channel as diagnostics; it **never rewrites your file**. `off` (default) does nothing and the diagnostics surface is unchanged. `apply` is reserved for a future release and is treated as `off` |\n| `ruleset`          | `pses-default` | Live diagnostics ruleset tier. `pses-default` (default) keeps PSES's built-in no-settings rule set (about 15 rules) -- unchanged from prior versions. `base` opts in to the plugin's shipped enumerated base ruleset (PSScriptAnalyzer's default-on set minus the compatibility rules), broadening the live surface so `PSAvoidUsingWriteHost` and the three Error-severity security rules surface. A repo-local `PSScriptAnalyzerSettings.psd1` and an explicit `settingsPath` always win over the base. See [Ruleset tiers](#ruleset-tiers-opt-in-broaden) |\n\nDiagnostics are returned in a stable order (severity, then line, then column),\ndeduped, threshold- and rule-filtered, then capped per file.\n\nThese filters apply on top of whatever **PSES** publishes. By default (`ruleset` =\n`pses-default`) PSES runs its own built-in no-settings rule set for live analysis, which is\nnarrower than the `Invoke-ScriptAnalyzer` CLI default -- for example `PSAvoidUsingWriteHost`\nis not surfaced on the fly even though the CLI flags it. The filter knobs\n(`severityThreshold`, `ruleInclude`, `ruleExclude`) can *suppress or narrow* what PSES\nreports. To *broaden* the live surface instead, set `ruleset` = `base` -- or point\n`settingsPath` at your own settings file -- which replaces that built-in set with a resolved\nrule set (see [Ruleset tiers](#ruleset-tiers-opt-in-broaden) below).\n\n### Format-on-edit (suggest, never rewrite)\n\n`formatOnEdit` is **off by default**. When set to `suggest`, each time Claude edits a\nPowerShell file the warm daemon runs PSScriptAnalyzer's `Invoke-Formatter` over it --\nhonoring the repo's own `PSScriptAnalyzerSettings.psd1` formatter rules when present (the\nsame settings auto-discovery the analyzer uses) -- and surfaces the reformatted result as a\n**suggestion**: a compact unified diff, clearly labelled and distinct from a diagnostic,\nstating that the file was **not** modified. The hook **never writes your file** -- it only\nsuggests, so editing is never disrupted and you stay in control of what lands. A formatting\nfailure (no settings, a malformed settings file, a formatter error) degrades quietly: no\nsuggestion is shown, and the edit is never blocked. Formatting runs on the already-warm\ndaemon, so it adds no cold-start, and a file that already matches the configured style\nproduces no suggestion at all. Values are `off` (default) and `suggest`; `apply` is reserved\nfor a possible future release and currently behaves as `off`.\n\n### Ruleset tiers (opt-in broaden)\n\n`ruleset` is **`pses-default` by default**, which keeps today's live surface exactly: PowerShell\nEditor Services applies its own built-in no-settings rule set (about 15 PSScriptAnalyzer rules) on\nthe fly, and no plugin ruleset is resolved. Set `ruleset` = `base` to opt in to the plugin's shipped\n**base ruleset** (`rulesets/base.psd1`): PSScriptAnalyzer's full default-on set **minus** the\ncompatibility-profile rules, **enumerated explicitly** so the surfaced set is deterministic and does\nnot drift when the pinned analyzer is bumped (regenerate with `scripts/regen-base-ruleset.ps1`).\nOpting in broadens the live surface -- notably `PSAvoidUsingWriteHost` and the three Error-severity\nsecurity rules (`PSAvoidUsingComputerNameHardcoded`, `PSAvoidUsingConvertToSecureStringWithPlainText`,\n`PSAvoidUsingUsernameAndPasswordParams`) start surfacing where the built-in set omits them.\n\nPrecedence is always yours to control: an explicit `settingsPath` and a repo-local\n`PSScriptAnalyzerSettings.psd1` **both win over the base** -- the base only fills the gap when neither\nis present. The existing noise controls still apply on top: `scopeToEdit` (on by default) limits\nfindings to the lines you edited, `perFileCap` caps the count per file, and `severityThreshold` drops\nlow-severity findings -- so `base` broadens *what can surface* without flooding a single edit. The\ndefault is deliberately **not** flipped: the broadened surface never activates unless you opt in.\n\n\u003e **Privacy note -- `enableStats` logs absolute paths.** When `enableStats` is on (it is\n\u003e **off by default**), each timing line in `logs/stats.jsonl` records the **absolute path**\n\u003e of the analyzed file. All logs stay under your plugin data directory and are never\n\u003e transmitted, but if you share a log for a bug report, sanitize the paths first. (Path\n\u003e redaction may arrive as a later option; for now the caveat is the contract.)\n\n## Performance\n\nMeasured on `pwsh` 7.6.3, Windows 11, at the v1.12.0 build:\n\n- **Warm-path latency** (edit -\u003e diagnostic round-trip; median of 5 successive\n  real edits against an already-warm daemon): **~2.2 s** (median ~2210 ms; range\n  ~2154-2236 ms).\n- **Cold-start latency** (SessionStart hook -\u003e the per-session PSES daemon reaches\n  ready; median of 3): **~3.9 s** (median ~3892 ms; range ~3789-4561 ms).\n\nRoughly 0.7 s of the warm path is the per-hook `pwsh` process spawn that Claude\nCode pays regardless of plugin code.\n\nThese latencies are **measured and guarded in CI** by a repeatable benchmark\nharness (`tests/PowerShellLsp.Benchmark.Tests.ps1`): it times the real daemon/pipe\npath on all four CI legs (Windows `pwsh`, Windows PowerShell 5.1, Ubuntu, macOS),\nemits structured results (`benchmark-results.json`), and fails if a median\nregresses past a generous threshold. The first-pass bounds are deliberately loose\n(cold under 20 s, warm under 9 s) -- enough to catch a gross regression without\nflaking on slower hosted runners; they tighten as per-leg CI numbers are\ncharacterized.\n\nThe acceptance suite also confirms: cold-session bring-up launches exactly one\ndaemon; a deliberate diagnostic returns over the warm path; the settled\nPSScriptAnalyzer pass (not the early parser publish) is reported; file URIs carry\nuppercase drive letters; three rapid edits coalesce into one analysis pass;\nSessionEnd leaves no daemon/PSES processes; and killing the daemon mid-session\ndegrades gracefully (no stdout, under the hard cap) while the next SessionStart\nreaps the stale session and its orphaned PSES.\n\n## How it works (warm-start daemon)\n\nDiagnostics are delivered through a **PostToolUse hook backed by a warm,\nper-session daemon** -- one PSES stays hot for the whole session, so each edit\npays a pipe round-trip instead of a cold PSES start.\n\n```text\nSessionStart  -\u003e scripts/session-start.ps1\n                   ensure-pses.ps1   (idempotent PSES bootstrap, pinned tag)\n                   ensure-pssa.ps1   (idempotent PSScriptAnalyzer vendor, pinned)\n                   log sweep (keep-last-10 per family)\n                   reap OUR stale daemons (recorded pids only, verified)\n                   launch scripts/pses-daemon.ps1  (one warm PSES via -Stdio;\n                     named pipe powershell-lsp-\u003csessionid\u003e; pid/heartbeat in\n                     CLAUDE_PLUGIN_DATA/session/\u003csessionid\u003e.json)\n\nPostToolUse   -\u003e scripts/lsp-client.ps1\n                   read hook JSON (session_id, file_path) from stdin\n                   connect to the pipe, request diagnostics for the edited file\n                   daemon: didOpen/didChange -\u003e wait for the SETTLED PScriptAnalyzer\n                     publish (not the early parser publish) -\u003e debounce\n                   return deduped, severity-sorted diagnostics to Claude via\n                     hookSpecificOutput.additionalContext\n\nSessionEnd    -\u003e scripts/session-end.ps1\n                   pipe {shutdown} -\u003e daemon sends LSP shutdown/exit to PSES,\n                   removes its session file, exits\n```\n\n- **`scripts/lib/lsp-common.ps1`**: shared helpers (host detection, file-URI with\n  uppercase drive, LSP framing, diagnostics ordering/dedupe), dot-sourced by the\n  daemon, client, hooks, and tests.\n- **`scripts/ensure-pses.ps1`**: idempotent PSES bootstrap into\n  `${CLAUDE_PLUGIN_DATA}/PowerShellEditorServices`; no-op once present.\n- **`scripts/ensure-pssa.ps1`**: idempotent vendor of pinned PSScriptAnalyzer into\n  `${CLAUDE_PLUGIN_DATA}/modules`, prepended to the PSES child's `PSModulePath` so\n  the analyzer pass runs (PSES emits only parser errors without it).\n- **`scripts/pses-stdio.ps1`**: the cold-start `-Stdio` launcher -- the destination\n  for native `.lsp.json` registration (see below).\n\nAll scripts run `-NoLogo -NoProfile`, write nothing to stdout on the daemon/LSP\npath, and keep all state, logs, and pids under `CLAUDE_PLUGIN_DATA` only.\n\n## CI mode: SARIF + standalone scanning\n\nThe same diagnostics engine that runs in-agent is also a standalone gate you can wire into\nCI. `scripts/lsp-scan.ps1` runs over a path -- a single file or a whole directory -- and\nemits **SARIF 2.1.0** for GitHub code scanning, or a human-readable text report. (The first\nrun bootstraps PSES + the pinned PSScriptAnalyzer, exactly as a session does.)\n\n```powershell\n# Scan a directory, emit SARIF for code scanning (the default format):\npwsh -File scripts/lsp-scan.ps1 ./src -OutputPath results.sarif\n\n# Scan a single file, human-readable text:\npwsh -File scripts/lsp-scan.ps1 ./build.ps1 -Format text\n\n# Fail the build (exit 2) if any warning-or-worse finding is present:\npwsh -File scripts/lsp-scan.ps1 ./src -Format text -FailOn warning\n```\n\n**One engine, in-agent and in-CI.** The scan is a *sibling* invocation of the exact same\npath the PostToolUse hook uses: it brings up the same warm PSES daemon and runs the same\n`scripts/lsp-client.ps1` over each file, so a finding is identical whether it surfaces while\nClaude edits or in your CI. This is not a re-implementation -- a test\n(`tests/PowerShellLsp.SarifScan.Tests.ps1`) runs the whole diagnostic-correctness corpus\nthrough the scan entry point and asserts its findings match the in-agent snapshots exactly\n(the same measured 0% false-positive / 100% true-positive numbers). PSScriptAnalyzer is the\nsame pinned, SHA-256-verified vendor; there is no second acquisition path.\n\n**What is scanned.** Only the file types the tool handles -- `.ps1`, `.psm1`, `.psd1`. A\ndirectory is **recursed by default** (`-NoRecurse` limits to the top level); every\nnon-PowerShell file is skipped (and counted in the text summary). The repository's own\n`PSScriptAnalyzerSettings.psd1` is honored, exactly as in-agent.\n\n**Severity mapping (honest -- no inflation, no deflation).** The tool's diagnostic\nseverities map to SARIF result levels as:\n\n| Tool severity | SARIF level |\n|---------------|-------------|\n| Error         | `error`     |\n| Warning       | `warning`   |\n| Information   | `note`      |\n| Hint          | `note`      |\n\nSARIF 2.1.0 has exactly four levels (`error`, `warning`, `note`, `none`). The only fold is\nInformation **and** Hint to `note`, because SARIF has no separate info/hint level below\nwarning; nothing is mapped to `none` (which would suppress it from code-scanning views), and\nan unknown severity maps to `warning`, so a finding is never silently dropped. The emitted\nSARIF is validated against the official SARIF 2.1.0 JSON Schema in CI.\n\n**Output format is a CLI parameter, not a config knob.** `-Format sarif|text` is an\nentry-point parameter -- the CI invocation is explicit, so the choice is a parameter, not a\n`userConfig` knob. (The 1.x contract freezes the knob names and status tokens; this entry\npoint adds neither, so `CONTRACT.md` is unchanged.)\n\n**Exit codes.** `0` = completed (clean, or under the `-FailOn` threshold); `2` = `-FailOn`\nthreshold met; `3` = usage error (no PowerShell host, or the path does not exist); `4` =\nscan incomplete (the analyzer was not reachable -- an unanalyzed file is never reported as a\nclean one).\n\nA minimal GitHub Actions step that uploads the results to code scanning:\n\n```yaml\n- shell: pwsh\n  run: ./scripts/lsp-scan.ps1 . -OutputPath results.sarif\n- uses: github/codeql-action/upload-sarif@v3\n  with:\n    sarif_file: results.sarif\n```\n\n## Why a hook, not native `.lsp.json` registration\n\nClaude Code declares plugin language servers through an inline `lspServers` block in\n`plugin.json` (or a standalone per-plugin\n[`.lsp.json`](https://code.claude.com/docs/en/plugins-reference#lsp-servers) file). This plugin\ncarries the inline block. As of v1.18.1 the manifest-side blocker that kept it from registering\nis removed -- so native **registration** is no longer the obstacle. The plugin still ships\ndiagnostics over a **warm PostToolUse hook** for one reason: **registration is restored, but\nend-to-end serve is not.**\n\nOnce the server is registered, Claude Code launches it and PSES reaches \"Starting Language\nServer\", but Claude Code's LSP client currently times out during initialization (the\n`#1359`-class server-\u003eclient init handshake). So a native `goToDefinition` / `hover` /\n`findReferences` on a `.ps1` does not complete yet -- it is gated **upstream**, on the Claude\nCode side, not on this plugin's launcher (which is provably stdout-clean: its first stdout line\nis a valid `Content-Length:` LSP header). The warm hook, by contrast, works on every supported\nhost today and does not depend on the native path at all. The hook is the product; native\nregistration is the bonus, now one upstream fix away from serving.\n\n### What used to block registration, and what fixed it\n\nFor a long stretch (Claude Code 2.1.167 through 2.1.183) the native path looked **inert** -- every\nprobe returned `No LSP server available for file type: .ps1` -- and two upstream issues were the\nleading suspects:\n\n1. **A marketplace packaging gap.** A marketplace install copies only the plugin's source\n   directory, so an `lspServers` block living **solely** in `marketplace.json` is dropped and the\n   installed plugin registers **0 servers**. Tracked (open) at\n   [claude-plugins-official#379](https://github.com/anthropics/claude-plugins-official/issues/379);\n   the proposed fix [PR #378](https://github.com/anthropics/claude-plugins-official/pull/378) was\n   **closed unmerged** (2026-02-11). This plugin sidesteps it by declaring the server inline in\n   `plugin.json`, which the installer does copy.\n2. **A registration race.** `LspServerManager` could initialize before plugins finished loading.\n   First reported in\n   [claude-code#14803](https://github.com/anthropics/claude-code/issues/14803) (**fixed**) and\n   analyzed in [#29858](https://github.com/anthropics/claude-code/issues/29858);\n   [#15168](https://github.com/anthropics/claude-code/issues/15168) /\n   [#15148](https://github.com/anthropics/claude-code/issues/15148) track the residual symptom.\n\nOn Claude Code 2.1.195, a controlled single-field probe matrix (dispatch 000069) showed neither of\nthose was what blocked **this** plugin: the official `typescript` control plugin registers and\nserves, and a clean known-good `lspServers` block in a `plugin.json` registers too -- so the\nplatform path is effective. The real blocker was **two fields in our own manifest**:\n\n\u003e Claude Code's runtime LSP registrar **silently drops** any `lspServers` entry that declares\n\u003e **`restartOnCrash`** or **`shutdownTimeout`**. Both are accepted by the plugin-manifest JSON\n\u003e schema (so `plugin.json` validates), but the registrar rejects them with no diagnostic -- and our\n\u003e block declared both, so `.ps1 -\u003e powershell` was never registered.\n\nRemoving those two fields (v1.18.1) clears the obstacle; a CI guard\n(`tests/PowerShellLsp.Unit.Tests.ps1`) now fails if any `lspServers` entry re-declares a field\noutside the registrar-supported set `{command, args, extensionToLanguage, transport,\nstartupTimeout, maxRestarts, env}`. Full methodology and the 23-probe matrix are in\n[`docs/upstream/claude-code-lsp-registration.md`](docs/upstream/claude-code-lsp-registration.md).\n\n### The standalone `.lsp.json` template\n\nThe plugin's `plugin.json` already carries the registrar-clean `lspServers` block, so native\nregistration needs no extra step once the plugin is enabled -- there is nothing to copy in. A\nstandalone copy of the declaration also ships at\n[`docs/lsp.json.template`](docs/lsp.json.template) for reference and for any setup that wants a\nroot-level `.lsp.json`; it is deliberately **not** live at the repo root, because a second,\nduplicate declaration would risk double registration.\n\n\u003e **Heads-up for when serve lands -- duplicate diagnostics.** If native serving ever completes\n\u003e (the upstream init handshake is fixed) while the PostToolUse hook is also enabled, each\n\u003e diagnostic could arrive twice. Use one path or the other.\n\n## Pinned versions\n\n| Component         | Version  | Pinned in                 | Source                                  |\n|-------------------|----------|---------------------------|-----------------------------------------|\n| PSES              | `v4.6.0` | `scripts/ensure-pses.ps1` (`$PsesTag`)     | GitHub release `PowerShellEditorServices.zip` |\n| PSScriptAnalyzer  | `1.25.0` | `scripts/ensure-pssa.ps1` (`$PssaVersion`) | PowerShell Gallery                      |\n\nTo bump either, change the single pin variable named above and start a fresh\nsession (the ensure-step re-vendors at the new version, keyed by a per-version\nmarker). See [CHANGELOG](./CHANGELOG.md#versioning) for how a bump maps to SemVer.\n\nIn CI, the pinned PSScriptAnalyzer `.nupkg` is cached (`actions/cache`, keyed by the\npinned version **and** SHA-256) and restored on a cache hit, so the PowerShell\nGallery is contacted only on a miss -- the analyzer-acquisition step does not depend\non live Gallery egress every run. The integrity pin is still load-bearing on every\npath: a restored `.nupkg` is run through the exact same SHA-256 verification as a\nfresh download before use, and a poisoned or stale cache entry fails closed (it is\nrefused, never installed). The cache is a transport optimization, never a trust\nshortcut. For a normal install (no `POWERSHELL_LSP_PSSA_CACHE` set) acquisition is\nunchanged: download, verify against the pin, then vendor.\n\n## Platform support\n\nAs of 1.1.1 the **hooks require `pwsh` (PowerShell 7)** -- they launch the bootstrap\nunder it on every platform. Windows PowerShell 5.1 is supported as the **PSES child\nhost** (set `ps_host` to `powershell`), not as the hook interpreter.\n\nCI runs the Pester suite on a four-leg matrix: **Windows `pwsh` 7**, **Windows\nPowerShell 5.1**, **Ubuntu `pwsh`**, and (as of 1.3.0) **macOS `pwsh`**. The full\nwarm-daemon **integration suite** (one-daemon bring-up, the settled PSScriptAnalyzer\npass, clean SessionEnd) runs and is **green on all four legs** -- so the **Linux and\nmacOS daemon paths are CI-verified**, not merely authored. The integration tests drive the daemon under\n`pwsh` on every leg, so the Windows-PowerShell-5.1 leg's distinct value is exercising\nthe **shared-library surface under 5.1** -- file-URI casing, BOM-tolerant stdin, the\n`ArgumentList`-vs-quoted-`.Arguments` split, and the config-env fallback -- the code\nthat must keep working when PSES runs as a 5.1 child.\n\nThe scripts are cross-platform: all paths go through `Join-Path`, host detection is\nshared, the single Windows-only call (process command-line lookup, used to verify a\npid is ours before any kill) is guarded behind `Test-OnWindows` with Linux `/proc`\nand macOS `ps` fallbacks, and the client/daemon transport is `System.IO.Pipes` (Unix\ndomain socket semantics on *nix). As of 1.3.0 that macOS `ps` fallback is exercised by\nthe macOS CI integration leg, so **macOS is CI-verified** alongside Linux.\n\n## Diagnostics status\n\nEvery analyzed edit resolves to one of four statuses. The clean case is silent; the other\nthree surface a one-line banner in Claude's context, so a result is never *mistaken* for\n\"analyzed, clean\" when it was not actually analyzed. The wording is owned in one place\n(`Get-DiagnosticsStatusBanner` in `scripts/lib/lsp-common.ps1`).\n\n| Status            | When                                                                 | What you see / what to do |\n|-------------------|----------------------------------------------------------------------|---------------------------|\n| **`ok`**          | The PSScriptAnalyzer pass settled and the analyzer was available.    | Nothing extra -- diagnostics (if any) are shown, no banner. The warm happy path. |\n| **`incomplete`**  | The pass did **not** settle for this edit -- PSES timed out, threw, exited, a supervised re-spawn was mid-flight, or PSES is **still starting** (pipe-first opens the request pipe before PSES is ready, dispatch 000028). | `analysis did not complete -- this edit was NOT checked.` Transient: the next edit usually succeeds once PSES is ready. |\n| **`degraded`**    | PSES is up and settled, but the vendored **PSScriptAnalyzer is absent**, so only the parser ran. | `parser-only mode -- PSScriptAnalyzer unavailable, lint rules were NOT checked (syntax errors are still reported).` Start a fresh session so `ensure-pssa` re-vendors; see `logs/ensure-pssa.log`. |\n| **`unavailable`** | PSES **could not start at all**, for the whole session -- either the bundle **never bootstrapped** (a clean box, offline or behind a proxy) or it is present but **failed to initialize** (a startup failure / init timeout, dispatch 000028). | `PowerShell editor services could not start -- not installed (the bootstrap did not complete), or installed but failed to start. Diagnostics will stay OFF for this whole session until it is fixed and the session is restarted.` Fix the install/startup, then start a fresh session; see `logs/ensure-pses.log` and `logs/pses-daemon.log`. |\n\n`incomplete` (transient -- \"not ready/settled this time, the next edit will be\") and\n`unavailable` (permanent for the session -- \"could not start; fix and restart\") are\ndeliberately distinct, with distinct remedies. The install-time `unavailable` arrived in 1.5.0\n(dispatch 000024); 1.6.0 (dispatch 000028) made the daemon **pipe-first** -- it opens the\nrequest pipe *before* bringing PSES up -- so a first edit that races startup now gets one of\nthese honest banners instead of silence, and generalized `unavailable` to also cover a\npresent-but-failed start (not just a missing install). When the daemon is unreachable entirely --\nno pipe at all (the brief daemon-launch window, or a session whose daemon has stopped after idle) --\nthe PostToolUse client surfaces its own honest \"analyzer was not reachable -- this edit was NOT\nchecked\" banner (start a new session to restart the daemon), so even the no-pipe case is never\nsilent. The mid-session `incomplete`/`degraded` split was introduced earlier (dispatch 000022).\n\n## Dogfood diagnostic capture\n\nEvery diagnostic the plugin surfaces is also **teed to a local, append-only log** so the real\ndiagnostics from real day-to-day editing can drive the roadmap's quality work -- rule curation,\nfalse-positive reduction, fix-suggestion quality -- ranked on evidence instead of guesses. The\ncompanion tool that annotates this log -- filling each `verdict` -- is documented in **Dogfood\nreview** below.\n\n- **Where:** `dogfood/diagnostics.jsonl` in the plugin tree. Override with the\n  `POWERSHELL_LSP_DOGFOOD_LOG` environment variable (a full path to the `.jsonl` file).\n- **What:** one JSON object per line, one line per diagnostic **occurrence** -- two identical\n  diagnostics make two lines (frequency is the signal; de-duplication is an analysis-time concern,\n  never a capture-time one). Each entry carries: `ts` (ISO-8601), `file`, `line`, `col`, `ruleId`\n  (the PSScriptAnalyzer rule, or empty for a parser error), `source` (`PSScriptAnalyzer` or\n  `parser`), `severity`, `message`, `snippet` (the full offending line), `hash` (a stable key over\n  the rule id + the normalized offending-line shape, for analysis-time de-duplication), and\n  `verdict` -- written **empty**, reserved for you to annotate later with `scripts/review-dogfood.ps1`\n  (see **Dogfood review** below).\n- **Invisible side channel:** capture runs *after* the diagnostics are surfaced and is fully\n  fail-safe. If the write fails for any reason, the diagnostics you see and the hook's exit code are\n  byte-for-byte unchanged; logging never changes, reorders, delays, or gates what is surfaced.\n\n\u003e **Never commit this log.** It holds **real source snippets** from the files you edit. The whole\n\u003e `dogfood/` directory is gitignored (see `.gitignore`) and must never be staged, added, or\n\u003e committed -- do not weaken that entry.\n\n## Dogfood review\n\nThe offline tool `scripts/review-dogfood.ps1` fills the empty `verdict` field that the capture\nreserves. It never changes what the daemon or hooks run and never alters the diagnostics surface or\nthe capture log. Instead, it turns raw captured diagnostics into ranked input for the roadmap's\nquality work (rule curation, false-positive reduction, fix quality).\n\n- Collapses captured occurrences into distinct diagnostic **shapes**, keyed by the record's `hash`\n  (rule id + normalized offending-line shape). Identical diagnostics share one verdict, so a misfire\n  seen many times is judged once; re-runs skip shapes that already have a verdict (resumable).\n- Fixed verdict vocabulary (lower-case): `useful` (true, actionable), `false-positive` (the rule\n  misfired), `noisy` (correct but low-value / clutter), `bad-fix` (the finding is fine but its\n  suggested correction is wrong / harmful), `unsure` (needs a second look). It is a fixed enum, not\n  free text; an optional one-line rationale may accompany a verdict.\n- **Persistence:** verdicts are written to a **separate sibling file**, `dogfood/annotations.jsonl`,\n  keyed by the shape hash. Append-only, last-write-wins (a corrected verdict appends a new line;\n  readers honor the latest). The capture log (`diagnostics.jsonl`) is never rewritten -- it stays\n  immutable evidence.\n- **Read-only by default:** with no write action the tool lists the pending shapes and prints a\n  **summary** (counts by verdict, annotation coverage, the source split, and the top \"actionable\"\n  rules -- those verdicted false-positive / noisy / bad-fix -- ranked by occurrence count). Writing a\n  verdict is the explicit action.\n- **Reading the right log (`-Source`):** by default (`-Source auto`) the reviewer reads the\n  **installed marketplace-cache** log -- the one the live hook writes to under normal installed use --\n  when it exists and is non-empty, so a review run from the dev checkout is not blind to the real\n  captures; otherwise it falls back to the running-tree (checkout) log. Force one with `-Source cache`\n  or `-Source checkout`. The versioned cache path is **discovered** (it follows `CLAUDE_PLUGIN_ROOT`\n  when set, else picks the current installed version under the plugin cache tree) -- never hardcoded.\n  This is a read-side locator only; it never changes where the hook writes.\n- **Source split:** the summary also buckets captures **by source** -- `canonical-checkout` (edits of\n  the real checkout), `other-genuine` (linked worktrees, the demo recording, other repos), and\n  `synthetic` (temp / Pester-fixture paths) -- so the quality wave can tell real canonical source from\n  the rest. An ambiguous path is classified conservatively (never as `canonical-checkout`).\n- **Recording a verdict:** non-interactively with `-Hash \u003chash\u003e -Verdict \u003cverdict\u003e [-Rationale\n  \"...\"]`, or interactively with `-Review` (a guarded prompt loop over pending shapes; on a\n  non-interactive host it falls back to the read-only listing instead of blocking).\n- Use `-Redact` to mask the offending-line snippet in listings when sharing a review. Other flags:\n  `-Summary` (summary only), `-All` (list every shape, not just pending), `-Source`\n  (`auto` / `cache` / `checkout`), `-Path` and `-AnnotationsPath` (point at explicit files).\n\n```text\npwsh -File scripts/review-dogfood.ps1\npwsh -File scripts/review-dogfood.ps1 -Summary\npwsh -File scripts/review-dogfood.ps1 -Source cache\npwsh -File scripts/review-dogfood.ps1 -Review\npwsh -File scripts/review-dogfood.ps1 -Hash \u003chash\u003e -Verdict false-positive -Rationale \"...\"\n```\n\n\u003e **Never commit the annotations file either.** It lives under the same already-gitignored\n\u003e `dogfood/` directory as the capture log, so the `.gitignore` already covers it -- do not weaken\n\u003e that entry. Its free-text rationale could quote source, so it stays local-only like the log.\n\n## Diagnostic-correctness corpus\n\nA curated corpus (`tests/corpus/`) proves the diagnostics the tool *reports* are correct -- not\nmerely present, and not merely honest when it cannot analyze. Three sample categories:\n\n- **clean** (34 cases) -- expect zero findings (no false positives on clean code); a deliberately\n  broad span of real-world idioms (advanced functions with `begin`/`process`/`end`, classes with\n  inheritance and static members, `[Flags]` enums, validation attributes, `SecureString` /\n  `PSCredential` parameters, splatting, multi-stage pipelines, typed `try`/`catch`/`finally`,\n  here-strings, regex, `ShouldProcess`, and more).\n- **known-bad** (36 cases) -- six cases per surfaced rule, each tripping a specific PSScriptAnalyzer\n  rule the tool surfaces, asserting the exact rule id, line, and severity; the several cases per\n  rule exercise varied triggering constructs.\n- **parser-error** (3 cases) -- expect parser diagnostics.\n\n**Measured correctness (default config, all four CI legs).** Across those curated cases the tool\nposts a **0% false-positive rate** (0 of 34 known-good cases produced any finding) and **100%\ntrue-positive coverage** (36 of 36 known-bad cases surfaced their expected rule), spanning every\nrule the default ruleset surfaces. These numbers are not prose -- they are recomputed from the live\ntool on every CI run and **guarded** (`tests/PowerShellLsp.Corpus.Tests.ps1`: the report fails CI if\nthe false-positive rate rises above zero, coverage drops below 100%, the corpus shrinks below 30\nknown-good or 30 known-bad, or any surfaced default rule loses its known-bad case), and the per-run\nreport is uploaded as a CI artifact (`logs/corpus-correctness-report.json`). The claim is *measured\nand defensible*, not *exhaustive*.\n\n**The invariant that makes it trustworthy:** every expected finding is *derived* by running the\nREAL tool over the sample and snapshotting exactly what it emits (through the plugin's own dogfood\ncapture channel) -- never hand-authored, never model-authored. A generator\n(`tests/corpus/Update-CorpusSnapshots.ps1`) writes the committed snapshots; the corpus test\nre-derives the same way and asserts the live tool still matches. A future behavior change becomes a\nvisible, located failure, and a hand-edited snapshot cannot make the test pass -- it would simply\ndisagree with the real tool.\n\nOne fact the corpus surfaced: the tool's effective default ruleset (via PowerShell Editor Services)\nis **narrower** than raw PSScriptAnalyzer. Measured against the live daemon, it surfaces **six** rules\non the fly -- `PSAvoidUsingCmdletAliases`, `PSUseApprovedVerbs`,\n`PSUseDeclaredVarsMoreThanAssignments`, `PSAvoidUsingPlainTextForPassword`,\n`PSPossibleIncorrectComparisonWithNull`, and `PSAvoidDefaultValueSwitchParameter` -- and drops others\nthe CLI flags (e.g. `PSAvoidUsingEmptyCatchBlock`, `PSReviewUnusedParameter`,\n`PSUseShouldProcessForStateChangingFunctions`, `PSAvoidUsingWriteHost`,\n`PSAvoidUsingPositionalParameters`, `PSUseSingularNouns`). The corpus records what the tool actually\nsurfaces; tuning the ruleset is a separate, dogfood-paced quality track. The corpus runs in CI on all\nfour legs.\n\n## Troubleshooting\n\n### Preflight self-check (the doctor)\n\nBefore chasing a specific symptom, run the preflight **doctor** -- it checks the\nprerequisites and bootstrap health in one place and prints a named fix-list:\n\n```\npwsh -File scripts/doctor.ps1\n```\n\nIt verifies, in order: PowerShell 7 (`pwsh`) is present and new enough (see\n[Prerequisites](#prerequisites)); the plugin is enabled (see [Quick start](#quick-start)); the PSES\nbundle and PSScriptAnalyzer finished bootstrapping (the pinned markers plus\n`Start-EditorServices.ps1`, see [Pinned versions](#pinned-versions)); the first-run\ndownload hosts are reachable; and the **warm per-session daemon** is alive and answering on its\nnamed pipe -- the *runtime* check the first five cannot make (they confirm the bundle is\n**installed**; this confirms the language server is actually **running**). Each check reports\n`PASS`, a specific failure with the fix, or an honest `UNKNOWN` when it genuinely cannot\ndetermine -- for example, run outside a Claude Code session it cannot see the plugin data\ndirectory, so the enable-state, bundle, and daemon checks report `UNKNOWN` (run it from inside\nan enabled session for a definitive result).\n\nThe daemon check **observes only** -- it never starts, restarts, or kills the daemon -- and it\nis honest about the auto-relaunch design (see [Diagnostics status](#diagnostics-status)): **no\ndaemon running** reports `PASS` (benign -- one auto-relaunches on your next edit), never a scary\nfailure, while a daemon that is alive but parked `unavailable` / `degraded`, or alive but not\nanswering its pipe, is a `FAIL` with the restart remedy.\n\nThe doctor is **report-only**: it never downloads, repairs, runs the bootstrap, or\nstarts/restarts the daemon. It also does **not** probe security controls itself -- but when a *bootstrap* failure is\ncaused by one, the SessionStart banner now names the most likely control and the\nlegitimate fix (see [Security-control blocks on managed Windows](#security-control-blocks-on-managed-windows)\nbelow). If a doctor check fails for a reason its own fix does not resolve, a security\ncontrol on a managed machine (an execution or application-control policy) may be the\ncause -- check that banner and the section below.\n\n### Symptoms\n\n- **Hooks fail with `'pwsh' is not recognized` / pwsh not found:** as of 1.1.1 the\n  hooks launch under PowerShell 7. Install it (`winget install Microsoft.PowerShell`)\n  -- Windows PowerShell 5.1 alone cannot launch the hooks. (`ps_host` only selects the\n  PSES *child* host, not the hook interpreter.)\n- **A leftover user-level PSES hook fires alongside the plugin (duplicate or\n  conflicting diagnostics):** if you previously wired a PowerShell diagnostics hook\n  directly in `~/.claude/settings.json` (a pre-plugin setup), remove it -- the plugin\n  owns the SessionStart / PostToolUse / SessionEnd hooks now, and a stray user-level\n  hook will double up or conflict with them.\n- **`/plugin` Errors tab shows `Executable not found in $PATH`** for the\n  `powershell` server: `ps_host` points at an executable that is not on PATH.\n  Install PowerShell 7 (`pwsh`) or set `ps_host` to `powershell`.\n- **No diagnostics / server never starts:** confirm the bootstrap ran by checking\n  that\n  `${CLAUDE_PLUGIN_DATA}/PowerShellEditorServices/PowerShellEditorServices/Start-EditorServices.ps1`\n  exists. If not, start a fresh session so the `SessionStart` hook can run, and\n  inspect `${CLAUDE_PLUGIN_DATA}/logs/ensure-pses.log`.\n- **Server starts but handshake fails:** inspect the PSES log under\n  `${CLAUDE_PLUGIN_DATA}/logs/pses-lsp.log/StartEditorServices-\u003cpid\u003e.log` for the\n  PSES-side error.\n- **`PrepareRenameHandler` `NullReferenceException` on initialize:** a PSES\n  `v4.6.0` bug -- its rename handler dereferences a null `RenameCapability` when an\n  LSP client's `textDocument` capabilities **omit** `rename`. This plugin's daemon\n  **declares a minimal `rename` capability on purpose**, which is what *avoids* the\n  NRE, so the warm path is unaffected. You would only hit this by driving PSES from\n  a client that omits rename (e.g. a hand-rolled minimal client against the cold\n  `-Stdio` launcher); if so, pin PSES `v4.5.0` in `scripts/ensure-pses.ps1`\n  (`$PsesTag`), which predates the rename handler.\n\n### Security-control blocks on managed Windows\n\nPowerShell developers often work inside locked-down Windows estates, and this plugin does\nexactly what those estates gate: it **downloads** executables (PSES, PSScriptAnalyzer),\n**runs** PowerShell, and **spawns** a daemon. When a security control blocks one of those\nat first start, the bootstrap fails -- and instead of a generic \"could not start\", the\nSessionStart banner now **names the most likely control and the legitimate remediation**.\nThe status stays `unavailable` (see [Diagnostics status](#diagnostics-status)); only the\nmessage gets specific.\n\nA control is named **only on positive evidence**, with calibrated confidence -- an\nuncertain case gets an honest \"here is how to check\" pointer, never a guessed control:\n\n| Control | How it is detected | Confidence | Banner names / fix |\n|---------|--------------------|------------|--------------------|\n| **ExecutionPolicy** (Group Policy) | `Get-ExecutionPolicy -List` shows `MachinePolicy`/`UserPolicy` = `AllSigned`/`RemoteSigned` (a command-line `-Bypass` is ignored when the policy is from GPO) | likely | the policy + scope. Fix: an admin allow-lists / signs the scripts, or adjusts the policy. |\n| **Constrained Language Mode** | the session `LanguageMode` is `ConstrainedLanguage` | likely | CLM. The plugin's .NET-using bootstrap cannot run under it. Fix: sign + policy-trust the plugin (admin). |\n| **App Control / WDAC** | a CodeIntegrity Operational event **3077** (enforced) or **3076** (audit) names a plugin component | confirmed / likely | the control + event id. Fix: an admin adds an allow rule. |\n| **Microsoft Defender ASR** | a Defender Operational event **1121** (block) or **1122** (audit) names a plugin component | confirmed / likely | the rule family + event id. Fix: an admin reviews / allows the rule. |\n| **Smart App Control** | the SAC registry state (`VerifiedAndReputablePolicyState`) is enforced / evaluation | possible | SAC is reputation-gated, so it is only ever *possible*. Fix: it relaxes as reputation accrues, or an admin turns it off. |\n| *(none identified)* | no positive evidence | -- | honest pointer: usually network/proxy; if managed, check `Get-ExecutionPolicy -List`, the language mode, and the CodeIntegrity log. |\n\nTo investigate a named (or suspected) block yourself, on the affected machine:\n\n```\nGet-ExecutionPolicy -List\n$ExecutionContext.SessionState.LanguageMode\nGet-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-CodeIntegrity/Operational'; Id = 3076, 3077 } -MaxEvents 20\n```\n\n**The plugin only ever detects and explains a block -- it never bypasses, disables, or\nmodifies a security control.** Every remediation above is something a user or their\nadministrator does deliberately (sign, allow-list, adjust policy); the plugin itself takes\nno such action. A tool that tried to circumvent enterprise security would deserve to be\nbanned -- honest degradation, telling you exactly what is blocked and how to allow it, is\nthe whole value.\n\n## Verify your install\n\nYou do not have to take this plugin's integrity on trust -- you can check it. The two pinned\ndependencies it downloads on first run are each verified against a SHA-256 computed from the real\nknown-good artifact *before* they are used, and a mismatch **fails closed** (the unverified bundle is\nrefused and the session reads `unavailable`). The pins and their hashes live in the repo, so you can\nconfirm the bytes on your machine match what this repo ships:\n\n```\n# The pinned versions + SHA-256 hashes are tabulated in TRUST.md; the pins themselves live in\n# scripts/ensure-pses.ps1 ($PsesTag / $PsesSha256) and scripts/ensure-pssa.ps1 ($PssaVersion /\n# $PssaSha256). Confirm a downloaded component matches the pin this repo ships:\n(Get-FileHash -Algorithm SHA256 -LiteralPath .\\PowerShellEditorServices.zip).Hash\n```\n\nEvery release cut by the **gated release pipeline** also ships a **CycloneDX SBOM**\n(`powershell-lsp-\u003cversion\u003e.cdx.json`, generated straight from those same pins, so it cannot disagree\nwith what the tool downloads) and a **SLSA build-provenance attestation** over the source archive,\nand the release **tag itself is keyless-signed via Sigstore** (gitsign) -- see\n**[Verifying a release](#verifying-a-release)** below for the download-and-verify steps, the tag\nsignature check, and what a pass proves.\n\nThe full pinned-hash table, the SBOM / provenance details, the **signing posture** (release tags\nkeyless-signed via Sigstore; scripts deliberately not Authenticode-signed; not independently\naudited), and paste-ready WDAC / AppLocker allow-list rules are all in **[TRUST.md](./TRUST.md)**.\n\n## Verifying a release\n\nEvery tagged release is built by this repository's own gated release pipeline, which publishes a\n**SLSA v1.0 build-provenance attestation** over the release archive and a **keyless gitsign (Sigstore)\nsignature on the release tag** -- both made through GitHub's OIDC identity, with **no maintainer-held\nkey** in the trust path. Anyone can verify a release. First download the archive for the version you\nwant:\n\n```\ngh release download v1.17.0 --repo manderse21/claude-powershell-lsp --pattern \"*.tar.gz\"\n```\n\nThen verify its provenance (a pass prints `Verification succeeded!` and exits 0; any mismatch fails\nnon-zero):\n\n```\ngh attestation verify powershell-lsp-1.17.0.tar.gz --repo manderse21/claude-powershell-lsp\n```\n\nA successful verification proves that exact archive:\n\n- **was built by this repository's release workflow**\n  (`.github/workflows/powershell-lsp-release.yml`) -- workflow identity, not another repo or a\n  hand-run command;\n- **is byte-identical to what was signed** -- its SHA-256 digest matches the attestation, so a\n  single tampered byte fails the check;\n- **carries SLSA v1.0 build provenance** -- a provenance predicate issued through GitHub's OIDC,\n  verifiable with no key the maintainer holds or could leak.\n\nYou can also verify the **signature on the release tag** itself. This needs\n[gitsign](https://github.com/sigstore/gitsign) installed -- a plain `git verify-tag` cannot read the\nx509 / Sigstore signature, and gitsign must be given the expected identity (it checks WHO signed, not\nmerely that a signature exists). Fetch the tags, then verify against this repository's release\nworkflow identity and the GitHub OIDC issuer:\n\n```\ngit fetch --tags\ngitsign verify \\\n  --certificate-identity=\"https://github.com/manderse21/claude-powershell-lsp/.github/workflows/powershell-lsp-release.yml@refs/heads/main\" \\\n  --certificate-oidc-issuer=\"https://token.actions.githubusercontent.com\" \\\n  v1.17.0\n```\n\nA successful verify confirms the tag was signed by THIS repository's release workflow under GitHub's\nOIDC issuer, anchored in the public Rekor transparency log.\n\n**What this does and does not prove.** This is build provenance and integrity over the downloadable\n**source archive** -- it proves the release came untampered from this repository's pipeline. It is\n**not** Windows Authenticode and does **not** assert a Windows verified-publisher identity (no\nSmartScreen reputation, no signed-script trust) -- Authenticode signing of the scripts is deliberately\nnot pursued for a git-distributed plugin. That is the correct boundary for a plugin distributed by\n`git clone`: the integrity of the normal `/plugin` install path rests on the **git commit and the\nkeyless-signed tag** themselves, not on the archive -- verify the tag as shown above, then trust the\ntree it names. See\n**[SECURITY.md](./SECURITY.md#verifying-release-integrity)** for the full step-by-step walkthrough\n(with sample output), and\n**[docs/RELEASING.md](docs/RELEASING.md#provenance-what-it-covers-and-what-it-does-not)** for exactly\nwhat the provenance covers.\n\n## Security and trust\n\nEvaluating this plugin for a managed or locked-down Windows estate? **[TRUST.md](./TRUST.md)**\nis the approve-or-deny reference: what runs locally and what never leaves the machine (no\nnetwork service, no telemetry), the **pinned + SHA-256-verified** downloads, the CycloneDX\nSBOM and build-provenance attestation, the **signing posture** (release tags keyless-signed via\nSigstore; scripts deliberately not Authenticode-signed; no security audit), paste-ready WDAC /\nAppLocker allow-list rules, and the governance / bus-factor posture.\n\nFound a vulnerability? See **[SECURITY.md](./SECURITY.md)** -- report it privately via GitHub\nprivate vulnerability reporting (never a public issue); it covers supported versions, scope,\nand what to expect.\n\n## Releasing\n\nReleases are cut by a **maintainer-triggered, gate-validated pipeline** -- never automatically\non push or merge. The pipeline refuses to tag unless the target commit is merged to `main`,\ngreen on every CI leg, and version-matched (`plugin.json` agrees with `marketplace.json`), then\ncuts the **keyless gitsign-signed** tag itself on that validated commit and publishes a GitHub\nRelease with CHANGELOG-sourced notes, a CycloneDX SBOM, and a build-provenance attestation. See\n[docs/RELEASING.md](docs/RELEASING.md) for how to trigger a release, what it validates, what it\nproduces, and the manual fallback.\n\n## Contributing and development\n\nContributions are welcome. Start with **[CONTRIBUTING.md](./CONTRIBUTING.md)** (prerequisites, how\nto run the suite, the test story), **[ARCHITECTURE.md](./ARCHITECTURE.md)** (how a diagnostic flows\nfrom edit to banner), and **[DEV_NOTES.md](./DEV_NOTES.md)** (the quirks that bite -- ASCII\ndiscipline, the 5.1 traps, the pipe-first daemon, the tool-derived corpus). Found a false positive?\nThe [report-a-false-positive form](./.github/ISSUE_TEMPLATE/false_positive_report.yml) feeds it\nstraight into the correctness corpus. The single-maintainer bus factor and the GPLv3 continuity path\nare stated honestly in **[CONTINUITY.md](./CONTINUITY.md)**.\n\n**Git hooks (contributors).** This repo ships a tracked pre-push guard that refuses a direct push to\n`origin/main` -- main lands via a reviewed, merged PR (the PR-and-HOLD discipline), never a local\npush. Enable it once per clone with `pwsh -File scripts/install-git-hooks.ps1`; it sets\n`core.hooksPath`, so the guard fires from linked worktrees too, not only the primary checkout. A\ndeliberate one-off is allowed and audited:\n`POWERSHELL_LSP_ALLOW_PUSH_TO_MAIN=\"\u003creason\u003e\" git push ...`. See\n[CONTRIBUTING.md](./CONTRIBUTING.md#git-hooks) for the override, the audit log, and the rationale.\n\n## License\n\n[GPL-3.0-or-later](https://spdx.org/licenses/GPL-3.0-or-later.html) (GPLv3). See [LICENSE](./LICENSE).\n\nThe change to GPLv3 is **forward-only**, effective from **v1.6.1**. Prior releases (v1.0 through\nv1.6.0) remain under the MIT license they shipped with -- that grant is irrevocable and is not\naffected by this change.\n\nPowerShell Editor Services and PSScriptAnalyzer are **downloaded at install time** (not bundled in\nthis repository) and remain under their own MIT licenses (Microsoft); MIT is GPL-compatible. See\n[THIRD-PARTY-LICENSES.md](./THIRD-PARTY-LICENSES.md).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmanderse21%2Fclaude-powershell-lsp","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmanderse21%2Fclaude-powershell-lsp","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmanderse21%2Fclaude-powershell-lsp/lists"}